Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 17.1.0

- Preserve document identity through reciprocal-rank fusion; same-id pages from different origins can no longer replace matching bytes.
- Build run-scoped citation handles and graph edges through the existing citation resolver. Unique ids remain unchanged; ambiguous origins are explicit and every returned handle resolves to the page actually ranked.
- Expose existing invalidation, tag and kind filters to `knowledge_search` without changing its defaults, write intake, or access scope.
- Share one typed reciprocal-rank fusion implementation between document ranking and the unchanged public string-key helper.


## 17.0.3 — 2026-09-20

Allow consumers to install Eval 0.182 or 0.183 alongside Knowledge.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Supply application callbacks for those decisions, or use `@tangle-network/agent-
## Install

```bash
pnpm add @tangle-network/agent-knowledge@17.0.3 @tangle-network/agent-eval@0.183.0 @tangle-network/agent-interface@2.10.0
pnpm add @tangle-network/agent-knowledge@17.1.0 @tangle-network/agent-eval@0.183.0 @tangle-network/agent-interface@2.10.0
```

Requires Node.js 20.19 or later.
Expand Down
30 changes: 30 additions & 0 deletions docs/run-scoped-citations.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,36 @@ cites:

Use `parseKnowledgeCitationReference()` and `formatKnowledgeCitationReference()` rather than assembling qualified strings in application code.

Names containing the delimiter are supported, not banned. When the legacy spelling would
lose identity, the formatter emits `knowledge-ref:v1:` followed by a URI-encoded JSON
`[origin, pageId]` tuple; a null origin means unqualified. For example, origin
`inherited:a::b` with page `c` must not alias origin `inherited:a` with page `b::c`.
Ordinary handles retain their previous bytes, and percent sequences in legacy handles
remain literal. The new prefix is reserved for encoded references; use the formatter
(or the structured reference API) for a literal page id beginning with that prefix.
Malformed encoded handles fail explicitly. Historical ambiguous handles are not guessed
or silently rewritten; retain the original evidence and qualify a new reference from
its known origin.

## Search and read use the same identity

`buildKnowledgeBrief` and `knowledge_search` rank distinct visible documents without
merging pages that share a bare id. Their returned `citationIds` and rendered links
use the resolver's qualified form whenever the full visible chain is ambiguous,
even if only one of those pages matches the query or survives a filter. Unique
ordinary ids retain their previous short form. Returned pages and retrieval
receipts preserve the original bytes and origins, not rewritten page objects.

Use each returned handle directly with `knowledge_read`, `knowledge_resolve`, or
`cites`; a query for a local page cannot be substituted with a same-id inherited
page. Ambiguous or malformed outgoing links are not graph-ranking evidence;
citation audit still sees the unchanged source page and can diagnose them.

`knowledge_search` accepts optional `excludeInvalidated`, `tags`, and `kinds` using
the existing brief semantics. For historical research, `excludeInvalidated: false`
includes refuted approaches; this does not make their claims valid or change the
host defaults for subsequent calls. Receipt identities capture the selected filters.

## Resolution

```ts
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tangle-network/agent-knowledge",
"version": "17.0.3",
"version": "17.1.0",
"description": "Build, search, evaluate, and improve source-backed knowledge bases.",
"homepage": "https://github.com/tangle-network/agent-knowledge#readme",
"repository": {
Expand Down
107 changes: 107 additions & 0 deletions src/citation-encoding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import {
formatKnowledgeCitationReference,
parseKnowledgeCitationReference,
resolveKnowledgeCitation,
} from './citation-resolution'
import { buildKnowledgeBrief } from './knowledge-brief'
import type { PageOrigin } from './run-scoped'
import type { KnowledgePage } from './types'

const page = (id: string, text: string): KnowledgePage => ({
id,
text,
title: text,
path: `${id}.md`,
outLinks: [],
tags: [],
sourceIds: [],
frontmatter: { id },
})

describe('citation serialization is injective, including legacy delimiter collisions', () => {
it('round trips every supported origin/page pair without banning existing names', () => {
const origins: (PageOrigin | undefined)[] = [
undefined,
'here',
'shared',
'inherited:a',
'inherited:a::b',
'inherited:a:',
'inherited:a%3A%3Ab',
'inherited:∑:研究',
'inherited:%/::x',
]
const ids = [
'c',
'b::c',
'here::literal',
'shared::',
'knowledge-ref:v1:literal',
'%3A%3A',
'quantum-∑',
'a::b::c',
]
const handles = new Set<string>()
for (const origin of origins) {
for (const pageId of ids) {
const reference = { pageId, ...(origin === undefined ? {} : { origin }) }
const handle = formatKnowledgeCitationReference(reference)
expect(parseKnowledgeCitationReference(handle)).toEqual(reference)
expect(handles.has(handle)).toBe(false)
handles.add(handle)
}
}
})

it('leaves normal handles and legacy literal percent sequences unchanged', () => {
expect(formatKnowledgeCitationReference({ origin: 'inherited:a', pageId: 'c' })).toBe(
'inherited:a::c',
)
expect(parseKnowledgeCitationReference('inherited:a%3A%3Ab::c')).toEqual({
origin: 'inherited:a%3A%3Ab',
pageId: 'c',
})
expect(formatKnowledgeCitationReference({ pageId: 'plain' })).toBe('plain')
})

it('keeps the two former colliding origins distinct through search and resolution', () => {
const first = page('c', 'Quantum first')
const second = page('b::c', 'Quantum second')
const visible = [
{ page: first, origin: 'inherited:a::b' as const },
{ page: second, origin: 'inherited:a' as const },
{ page: page('c', 'Banana local'), origin: 'here' as const },
]
const result = buildKnowledgeBrief(visible, 'quantum')
expect(result.results).toHaveLength(2)
expect(new Set(result.citationIds).size).toBe(2)
for (const [index, hit] of result.results.entries()) {
const resolved = resolveKnowledgeCitation(
visible,
parseKnowledgeCitationReference(result.citationIds[index]!),
)
expect(resolved.resolved?.page).toBe(hit.page)
expect(resolved.resolved?.origin).toBe(hit.origin)
}
})

it('retrieves literal reserved-prefix page names through the same formatter', () => {
const literal = page('knowledge-ref:v1:literal', 'Quantum reserved name')
const visible = [{ page: literal, origin: 'here' as const }]
const brief = buildKnowledgeBrief(visible, 'quantum')
expect(brief.results).toHaveLength(1)
expect(
resolveKnowledgeCitation(visible, parseKnowledgeCitationReference(brief.citationIds[0]!))
.resolved?.page,
).toBe(literal)
})

it('rejects malformed encoded references instead of silently changing their identity', () => {
for (const value of ['%', '%5B%5D', '%5Bnull%5D', '%5Btrue%2C%22p%22%5D']) {
expect(() => parseKnowledgeCitationReference(`knowledge-ref:v1:${value}`)).toThrow(
/invalid encoded knowledge citation/,
)
}
})
})
40 changes: 37 additions & 3 deletions src/citation-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,17 +91,40 @@ export class KnowledgeCitationAuditError extends Error {
}
}

// Legacy origin::page handles stay readable and byte-compatible. Use an explicit,
// versioned tuple only when delimiters or a reserved prefix would lose identity.
const ENCODED_REFERENCE_PREFIX = 'knowledge-ref:v1:'

/**
* Parse the persisted citation form.
*
* `page-id` is unqualified. `here::page-id`, `shared::page-id`, and
* `inherited:<runId>::page-id` bind an intentional duplicate to one origin.
* Ambiguous names use `knowledge-ref:v1:` plus a URI-encoded JSON [origin, pageId]
* tuple (null origin means unqualified). Always use the formatter to mint handles.
* Legacy percent sequences are literal; old records are not silently reinterpreted.
*/
export function parseKnowledgeCitationReference(value: string): KnowledgeCitationReference {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new TypeError('persisted knowledge citation must be a non-empty string')
}
const normalized = value.trim()
if (normalized.startsWith(ENCODED_REFERENCE_PREFIX)) {
try {
const tuple: unknown = JSON.parse(
decodeURIComponent(normalized.slice(ENCODED_REFERENCE_PREFIX.length)),
)
if (!Array.isArray(tuple) || tuple.length !== 2) {
throw new TypeError('encoded citation must contain an origin/page tuple')
}
return normalizeReference({
pageId: tuple[1],
...(tuple[0] === null ? {} : { origin: tuple[0] }),
})
} catch (cause) {
throw new TypeError('invalid encoded knowledge citation', { cause })
}
}
const separator = normalized.indexOf('::')
if (separator < 0) return Object.freeze({ pageId: normalized })
const possibleOrigin = normalized.slice(0, separator)
Expand All @@ -115,9 +138,20 @@ export function parseKnowledgeCitationReference(value: string): KnowledgeCitatio
/** Serialize one reference into the canonical frontmatter representation. */
export function formatKnowledgeCitationReference(reference: KnowledgeCitationReference): string {
const normalized = normalizeReference(reference)
return normalized.origin === undefined
? normalized.pageId
: `${normalized.origin}::${normalized.pageId}`
const legacy =
normalized.origin === undefined
? normalized.pageId
: `${normalized.origin}::${normalized.pageId}`
try {
const parsed = parseKnowledgeCitationReference(legacy)
if (parsed.pageId === normalized.pageId && parsed.origin === normalized.origin) return legacy
} catch (error) {
// A literal page id can itself look like a malformed reserved handle.
if (!(error instanceof TypeError)) throw error
}
return `${ENCODED_REFERENCE_PREFIX}${encodeURIComponent(
JSON.stringify([normalized.origin ?? null, normalized.pageId]),
)}`
}

/** Resolve one reference against an already materialized visibility chain. */
Expand Down
96 changes: 66 additions & 30 deletions src/knowledge-brief.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,20 @@
* Pure: no clock, no filesystem, no network.
*/
import { canonicalCandidateDigest, type Sha256Digest } from '@tangle-network/agent-interface'
import {
assertKnowledgeCitationsResolved,
formatKnowledgeCitationReference,
parseKnowledgeCitationReference,
resolveKnowledgeCitation,
} from './citation-resolution'
import type { OriginatedKnowledgeSearchResult } from './knowledge-use-receipts'
import type { OriginatedPage, PageOrigin } from './run-scoped'
import type { OriginatedPage } from './run-scoped'
import {
KNOWLEDGE_SEARCH_RETRIEVER_ID,
type KnowledgeSearchHit,
searchKnowledgePages,
} from './search'
import type { KnowledgeId } from './types'
import type { KnowledgeId, KnowledgePage } from './types'

/** Pages in a brief when the caller names no limit. */
export const DEFAULT_KNOWLEDGE_BRIEF_LIMIT = 5
Expand Down Expand Up @@ -87,21 +93,64 @@ export function buildKnowledgeBrief(
throw new Error(`knowledge brief maxChars must be a non-negative integer, got ${maxChars}`)
}

const originByPage = new Map<object, PageOrigin>()
for (const entry of visiblePages) originByPage.set(entry.page, entry.origin)

const ranked = searchKnowledgePages(
visiblePages.map((entry) => entry.page),
question,
{
limit,
excludeInvalidated,
...(options.tags === undefined ? {} : { tags: options.tags }),
...(options.kinds === undefined ? {} : { kinds: options.kinds }),
},
)
// Resolve citation addresses with the same owner used by read/write validation.
// Keep a small candidate list per id so resolving links does not rescan the corpus.
const byId = new Map<KnowledgeId, OriginatedPage[]>()
for (const entry of visiblePages) {
const matches = byId.get(entry.page.id) ?? []
matches.push(entry)
byId.set(entry.page.id, matches)
}
const qualified = (entry: OriginatedPage) =>
formatKnowledgeCitationReference({ pageId: entry.page.id, origin: entry.origin })
Comment on lines +104 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape delimiters in origin-qualified ranking IDs

When a lineage contains an ancestor ID with ::—which assertRunId currently permits—this encoding is not injective: { origin: 'inherited:a::b', pageId: 'c' } and { origin: 'inherited:a', pageId: 'b::c' } both become inherited:a::b::c. The resulting clones are treated as duplicate IDs by rankByGraph, suppressing valid graph edges, and any emitted qualified handle for the first page is parsed back as the wrong origin/page pair. Either reject the citation delimiter in run IDs or use an escaped/structured identity for ranking and serialization.

AGENTS.md reference: AGENTS.md:L7-L7

Useful? React with 👍 / 👎.

const originals = new Map<KnowledgePage, OriginatedPage>()
const pages = visiblePages.map((entry) => {
const page: KnowledgePage = {
...entry.page,
id: qualified(entry),
outLinks: entry.page.outLinks.flatMap((link: string) => {
// A malformed link is not a ranking edge; retain the page unchanged so
// citation audit can still diagnose it without making retrieval unavailable.
try {
const reference = parseKnowledgeCitationReference(link)
const resolution = resolveKnowledgeCitation(byId.get(reference.pageId) ?? [], reference)
return resolution.resolved ? [qualified(resolution.resolved)] : []
} catch (error) {
if (error instanceof TypeError) return []
throw error
}
}),
}
originals.set(page, entry)
return page
})
const ranked = searchKnowledgePages(pages, question, {
limit,
excludeInvalidated,
...(options.tags === undefined ? {} : { tags: options.tags }),
...(options.kinds === undefined ? {} : { kinds: options.kinds }),
}).map((hit) => {
const entry = originals.get(hit.page)!
const candidates = byId.get(entry.page.id)!
const reference = {
pageId: entry.page.id,
...(candidates.length === 1 &&
formatKnowledgeCitationReference({ pageId: entry.page.id }) === entry.page.id
? {}
: { origin: entry.origin }),
}
// The whole visible chain determines ambiguity, not just this query's filtered hits.
// Reusing one id within the SAME origin cannot be repaired with a qualifier.
assertKnowledgeCitationsResolved(candidates, [reference])
return {
...hit,
page: entry.page,
origin: entry.origin,
citationId: formatKnowledgeCitationReference(reference),
}
})

const hits: KnowledgeSearchHit[] = []
const hits: (KnowledgeSearchHit & OriginatedKnowledgeSearchResult)[] = []
const lines: string[] = []
let length = 0
for (const hit of ranked) {
Expand All @@ -125,9 +174,7 @@ export function buildKnowledgeBrief(
}),
hits: Object.freeze(hits),
citationIds: Object.freeze(hits.map((hit) => hit.citationId)),
results: Object.freeze(
hits.map((hit) => Object.freeze({ ...hit, origin: originOf(originByPage, hit) })),
),
results: Object.freeze(hits.map((hit) => Object.freeze(hit))),
text: lines.join('\n'),
})
}
Expand All @@ -138,14 +185,3 @@ function briefLine(hit: KnowledgeSearchHit): string {
? `- [${hit.citationId}] ${hit.page.title}`
: `- [${hit.citationId}] ${hit.page.title} — ${snippet}`
}

function originOf(
originByPage: ReadonlyMap<object, PageOrigin>,
hit: KnowledgeSearchHit,
): PageOrigin {
const origin = originByPage.get(hit.page)
if (origin === undefined) {
throw new Error(`knowledge brief ranked a page outside the visible chain: ${hit.page.path}`)
}
return origin
}
Loading
Loading