@fgv/ts-agent-memory 5.1.0-50 → 5.1.0-51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/packlets/retrieve/fragmentSemanticRetriever.js +94 -5
  2. package/dist/packlets/retrieve/fragmentSemanticRetriever.js.map +1 -1
  3. package/dist/packlets/store/fileTreeMemoryStore.js +13 -24
  4. package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
  5. package/dist/packlets/store/memoryStore.js.map +1 -1
  6. package/dist/packlets/store/storeIdentity.js +62 -0
  7. package/dist/packlets/store/storeIdentity.js.map +1 -0
  8. package/dist/packlets/types/identityResolver.js +6 -0
  9. package/dist/packlets/types/identityResolver.js.map +1 -0
  10. package/dist/packlets/types/index.js +1 -0
  11. package/dist/packlets/types/index.js.map +1 -1
  12. package/dist/packlets/vector/inMemoryFragmentCosineIndex.js +42 -2
  13. package/dist/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -1
  14. package/dist/packlets/vector/vectorIndex.js.map +1 -1
  15. package/dist/ts-agent-memory.d.ts +192 -9
  16. package/lib/packlets/retrieve/fragmentSemanticRetriever.d.ts +79 -1
  17. package/lib/packlets/retrieve/fragmentSemanticRetriever.d.ts.map +1 -1
  18. package/lib/packlets/retrieve/fragmentSemanticRetriever.js +94 -5
  19. package/lib/packlets/retrieve/fragmentSemanticRetriever.js.map +1 -1
  20. package/lib/packlets/store/fileTreeMemoryStore.d.ts +4 -2
  21. package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
  22. package/lib/packlets/store/fileTreeMemoryStore.js +13 -24
  23. package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
  24. package/lib/packlets/store/memoryStore.d.ts +2 -2
  25. package/lib/packlets/store/memoryStore.d.ts.map +1 -1
  26. package/lib/packlets/store/memoryStore.js.map +1 -1
  27. package/lib/packlets/store/storeIdentity.d.ts +38 -0
  28. package/lib/packlets/store/storeIdentity.d.ts.map +1 -0
  29. package/lib/packlets/store/storeIdentity.js +67 -0
  30. package/lib/packlets/store/storeIdentity.js.map +1 -0
  31. package/lib/packlets/types/identityResolver.d.ts +42 -0
  32. package/lib/packlets/types/identityResolver.d.ts.map +1 -0
  33. package/lib/packlets/types/identityResolver.js +7 -0
  34. package/lib/packlets/types/identityResolver.js.map +1 -0
  35. package/lib/packlets/types/index.d.ts +1 -0
  36. package/lib/packlets/types/index.d.ts.map +1 -1
  37. package/lib/packlets/types/index.js +1 -0
  38. package/lib/packlets/types/index.js.map +1 -1
  39. package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts +13 -2
  40. package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts.map +1 -1
  41. package/lib/packlets/vector/inMemoryFragmentCosineIndex.js +42 -2
  42. package/lib/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -1
  43. package/lib/packlets/vector/vectorIndex.d.ts +57 -6
  44. package/lib/packlets/vector/vectorIndex.d.ts.map +1 -1
  45. package/lib/packlets/vector/vectorIndex.js.map +1 -1
  46. package/package.json +7 -7
@@ -2,7 +2,8 @@
2
2
  * Copyright (c) 2026 Erik Fortune
3
3
  * SPDX-License-Identifier: MIT
4
4
  */
5
- import { fail, succeed } from '@fgv/ts-utils';
5
+ import { captureResult, fail, succeed } from '@fgv/ts-utils';
6
+ import { Convert } from '../types';
6
7
  /**
7
8
  * The loud-degradation message a {@link FragmentSemanticRetriever} returns when a
8
9
  * fragment query is issued but no {@link IFragmentSemanticBackend | backend} is
@@ -10,6 +11,26 @@ import { fail, succeed } from '@fgv/ts-utils';
10
11
  * @public
11
12
  */
12
13
  export const FRAGMENT_SEMANTIC_UNWIRED_MESSAGE = 'fragment recall: no fragment index is wired; wire an IFragmentSemanticBackend to enable sub-document search';
14
+ /**
15
+ * The loud-degradation message returned when a query carries a record narrowing but
16
+ * no {@link IIdentityResolver} is wired to resolve it.
17
+ *
18
+ * @remarks
19
+ * Deliberately a `Failure` rather than a silently-global search: answering a scoped
20
+ * question with an unscoped result is the failure this narrowing exists to remove.
21
+ * @public
22
+ */
23
+ export const FRAGMENT_NARROWING_UNRESOLVABLE_MESSAGE = 'fragment recall: a record narrowing was supplied but no identity resolver is wired; pass one to FragmentSemanticRetriever.create';
24
+ /**
25
+ * The message returned when exactly one of `entityId` / `kind` is supplied.
26
+ *
27
+ * @remarks
28
+ * They travel together because `kind` is what selects the identity codec, and the
29
+ * codec is what makes the resolution unambiguous. One without the other is not a
30
+ * partial narrowing that could be honored best-effort — it is not a narrowing at all.
31
+ * @public
32
+ */
33
+ export const FRAGMENT_NARROWING_INCOMPLETE_MESSAGE = 'fragment recall: `entityId` and `kind` must be supplied together — `kind` selects the identity codec that resolves the narrowing';
13
34
  /**
14
35
  * The sub-document semantic-search retriever — the "discovery" half of a
15
36
  * search-then-read contract. It embeds a fragment query, queries the
@@ -35,16 +56,26 @@ export const FRAGMENT_SEMANTIC_UNWIRED_MESSAGE = 'fragment recall: no fragment i
35
56
  * @public
36
57
  */
37
58
  export class FragmentSemanticRetriever {
38
- constructor(backend) {
59
+ constructor(backend, identityResolver) {
39
60
  this._backend = backend;
61
+ this._identityResolver = identityResolver;
40
62
  }
41
63
  /** What this retriever can do given its wiring. */
42
64
  get capabilities() {
43
65
  return { supportsFragmentRecall: this._backend !== undefined };
44
66
  }
45
- /** Family-convention factory. */
67
+ /**
68
+ * Family-convention factory.
69
+ *
70
+ * @param params - `backend` wires fragment recall itself. `identityResolver`
71
+ * resolves a query's `(kind, entityId)` narrowing to a storage address;
72
+ * `IMemoryStore` implements it, so the usual wiring is
73
+ * `{ backend, identityResolver: store }`. It is optional because an unscoped
74
+ * fragment search needs nothing to resolve — but a query that *does* carry a
75
+ * narrowing fails loudly without it rather than quietly searching everything.
76
+ */
46
77
  static create(params) {
47
- return succeed(new FragmentSemanticRetriever(params.backend));
78
+ return succeed(new FragmentSemanticRetriever(params.backend, params.identityResolver));
48
79
  }
49
80
  /**
50
81
  * Embed `query.semantic`, query the fragment index, and return the per-fragment
@@ -55,6 +86,13 @@ export class FragmentSemanticRetriever {
55
86
  return fail(FRAGMENT_SEMANTIC_UNWIRED_MESSAGE);
56
87
  }
57
88
  const backend = this._backend;
89
+ // Resolve the narrowing FIRST. It is synchronous, local, and cheap, while
90
+ // `embedQuery` is typically a paid network round trip — so a typo'd `kind`, a
91
+ // missing resolver, or a half-supplied narrowing should cost nothing.
92
+ const options = this._resolveOptions(query);
93
+ if (options.isFailure()) {
94
+ return fail(options.message);
95
+ }
58
96
  // Consumer-supplied hooks may throw; normalize both a returned `fail` and a
59
97
  // rejection into a single `fragment recall: <label> failed` Failure so
60
98
  // `retrieve` always honors its `Promise<Result<...>>` contract.
@@ -62,7 +100,58 @@ export class FragmentSemanticRetriever {
62
100
  if (embedded.isFailure()) {
63
101
  return fail(embedded.message);
64
102
  }
65
- return FragmentSemanticRetriever._callBackend('fragment query', () => { var _a; return backend.fragmentIndex.query(embedded.value, (_a = query.topK) !== null && _a !== void 0 ? _a : 10, query.maxPerRecord); });
103
+ return FragmentSemanticRetriever._callBackend('fragment query', () => { var _a; return backend.fragmentIndex.query(embedded.value, (_a = query.topK) !== null && _a !== void 0 ? _a : 10, options.value); });
104
+ }
105
+ /**
106
+ * Turn the query's consumer-facing narrowing into the storage-address narrowing
107
+ * the index understands.
108
+ *
109
+ * @remarks
110
+ * `kind` selects the identity codec and the codec computes the record's storage
111
+ * address, so this is a deterministic resolution rather than a search —
112
+ * which is what makes a colliding `entityId` across kinds a non-issue.
113
+ *
114
+ * A **versioned** kind resolves to the entity's own subtree scope and deliberately
115
+ * carries no `id`, so the narrowing covers every version of the entity — including
116
+ * superseded ones, which are invalidated but never pruned from the index. A
117
+ * non-versioned kind resolves to exactly one record.
118
+ */
119
+ _resolveOptions(query) {
120
+ const { entityId, kind, maxPerRecord } = query;
121
+ if (entityId === undefined && kind === undefined) {
122
+ return succeed({ maxPerRecord });
123
+ }
124
+ if (entityId === undefined || kind === undefined) {
125
+ return fail(FRAGMENT_NARROWING_INCOMPLETE_MESSAGE);
126
+ }
127
+ if (this._identityResolver === undefined) {
128
+ return fail(FRAGMENT_NARROWING_UNRESOLVABLE_MESSAGE);
129
+ }
130
+ const resolver = this._identityResolver;
131
+ // `identityResolver` is a consumer-injectable seam like the two backend hooks,
132
+ // so a throw has to become a `Failure` here rather than escaping `retrieve()`
133
+ // and breaking its `Promise<Result<...>>` contract. `captureResult` yields a
134
+ // nested `Result`, which the identity `onSuccess` flattens.
135
+ return captureResult(() => resolver.resolveIdentity(kind, entityId))
136
+ .onSuccess((resolved) => resolved)
137
+ .withErrorFormat((msg) => `fragment recall: cannot resolve '${kind}'/'${entityId}': ${msg}`)
138
+ .onSuccess((address) => {
139
+ // A versioned kind's every version lives under the entity subtree the
140
+ // codec returned, so omitting `id` is what makes the narrowing mean
141
+ // "this entity" rather than "one of its versions".
142
+ if (address.isVersioned) {
143
+ return succeed({ maxPerRecord, scope: address.scope });
144
+ }
145
+ // `idStem` is a plain `string` on the codec result, but a `MemoryId` IS the
146
+ // filename stem by contract and `Convert.memoryId` is what enforces that —
147
+ // so validate rather than assert. A resolver that returned a path-unsafe
148
+ // stem could otherwise smuggle it into the index query, where it would
149
+ // match nothing and look like an empty result rather than a caller bug.
150
+ return Convert.memoryId
151
+ .convert(address.idStem)
152
+ .withErrorFormat((msg) => `fragment recall: '${kind}'/'${entityId}' resolved to an unusable record id: ${msg}`)
153
+ .onSuccess((id) => succeed({ maxPerRecord, scope: address.scope, id }));
154
+ });
66
155
  }
67
156
  /**
68
157
  * Invoke a consumer-supplied backend hook, normalizing both a returned `fail`
@@ -1 +1 @@
1
- {"version":3,"file":"fragmentSemanticRetriever.js","sourceRoot":"","sources":["../../../src/packlets/retrieve/fragmentSemanticRetriever.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAU,IAAI,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAItD;;;;;GAKG;AACH,MAAM,CAAC,MAAM,iCAAiC,GAC5C,6GAA6G,CAAC;AA0ChH;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,OAAO,yBAAyB;IAGpC,YAAoB,OAA6C;QAC/D,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC;IAC1B,CAAC;IAED,mDAAmD;IACnD,IAAW,YAAY;QACrB,OAAO,EAAE,sBAAsB,EAAE,IAAI,CAAC,QAAQ,KAAK,SAAS,EAAE,CAAC;IACjE,CAAC;IAED,iCAAiC;IAC1B,MAAM,CAAC,MAAM,CAAC,MAEpB;QACC,OAAO,OAAO,CAAC,IAAI,yBAAyB,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC;IAChE,CAAC;IAED;;;OAGG;IACI,KAAK,CAAC,QAAQ,CAAC,KAAqB;QACzC,IAAI,IAAI,CAAC,QAAQ,KAAK,SAAS,EAAE,CAAC;YAChC,OAAO,IAAI,CAAC,iCAAiC,CAAC,CAAC;QACjD,CAAC;QACD,MAAM,OAAO,GAA6B,IAAI,CAAC,QAAQ,CAAC;QACxD,4EAA4E;QAC5E,uEAAuE;QACvE,gEAAgE;QAChE,MAAM,QAAQ,GAAyB,MAAM,yBAAyB,CAAC,YAAY,CACjF,iBAAiB,EACjB,GAAG,EAAE,CAAC,OAAO,CAAC,UAAU,CAAC,KAAK,CAAC,QAAQ,CAAC,CACzC,CAAC;QACF,IAAI,QAAQ,CAAC,SAAS,EAAE,EAAE,CAAC;YACzB,OAAO,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;QAChC,CAAC;QACD,OAAO,yBAAyB,CAAC,YAAY,CAAC,gBAAgB,EAAE,GAAG,EAAE,WACnE,OAAA,OAAO,CAAC,aAAa,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE,MAAA,KAAK,CAAC,IAAI,mCAAI,EAAE,EAAE,KAAK,CAAC,YAAY,CAAC,CAAA,EAAA,CAClF,CAAC;IACJ,CAAC;IAED;;;;OAIG;IACK,MAAM,CAAC,KAAK,CAAC,YAAY,CAAI,KAAa,EAAE,EAA4B;QAC9E,IAAI,CAAC;YACH,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,eAAe,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,oBAAoB,KAAK,YAAY,GAAG,EAAE,CAAC,CAAC;QAC3F,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,IAAI,CAAC,oBAAoB,KAAK,YAAY,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAClE,CAAC;IACH,CAAC;CACF","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result, fail, succeed } from '@fgv/ts-utils';\nimport { IFragmentVectorIndex, IVectorQueryHit } from '../vector';\nimport { QueryEmbedder } from './semanticRetriever';\n\n/**\n * The loud-degradation message a {@link FragmentSemanticRetriever} returns when a\n * fragment query is issued but no {@link IFragmentSemanticBackend | backend} is\n * wired — the discovery surface NEVER answers a fragment query with a silent empty.\n * @public\n */\nexport const FRAGMENT_SEMANTIC_UNWIRED_MESSAGE: string =\n 'fragment recall: no fragment index is wired; wire an IFragmentSemanticBackend to enable sub-document search';\n\n/**\n * The fragment backend wired into a {@link FragmentSemanticRetriever}: the fragment\n * index to query and the embedder that turns the query text into a vector. Both are\n * required together — a fragment index is useless without a way to embed the query.\n * @public\n */\nexport interface IFragmentSemanticBackend {\n /** The fragment-granular vector index to query. */\n readonly fragmentIndex: IFragmentVectorIndex;\n /** Turns the query text into a vector. */\n readonly embedQuery: QueryEmbedder;\n}\n\n/**\n * A sub-document semantic-search request: the natural-language `semantic` text to\n * match, an optional `topK` result cap (default 10), and an optional\n * `maxPerRecord` cap that keeps one long document from monopolizing the result.\n * @public\n */\nexport interface IFragmentQuery {\n /** The natural-language text to embed and match against stored fragments. */\n readonly semantic: string;\n /** Maximum number of fragment hits to return. Defaults to 10. */\n readonly topK?: number;\n /**\n * Maximum number of fragments any single record may contribute to the result.\n * Applied during selection (before the `topK` cut). Omit for uncapped.\n */\n readonly maxPerRecord?: number;\n}\n\n/**\n * What a {@link FragmentSemanticRetriever} can do given its wiring.\n * @public\n */\nexport interface IFragmentRetrieverCapabilities {\n /** `true` when a fragment backend is wired and fragment recall is operational. */\n readonly supportsFragmentRecall: boolean;\n}\n\n/**\n * The sub-document semantic-search retriever — the \"discovery\" half of a\n * search-then-read contract. It embeds a fragment query, queries the\n * {@link IFragmentVectorIndex}, and returns the raw per-fragment\n * {@link IVectorQueryHit | hits} (each carrying a record `target` AND whichever of\n * `locator` / `fragmentId` the stored fragment was indexed with), NOT resolved\n * records: the consumer re-reads each record and resolves the fragment on its own\n * read side. Note the `locator` span is advisory — see {@link IFragmentLocator}; it\n * is not a slice guaranteed to reproduce the fragment's text.\n *\n * @remarks\n * Deliberately NOT an {@link IMemoryRetriever}: memory recall is record-granular and\n * returns records; fragment discovery is fragment-granular and returns fragment\n * identities. Keeping it a distinct surface matches the consumer contract (memory\n * stays record-granular; sub-document knowledge uses a separate fragment index) and\n * avoids overloading the record retriever's return type with identity fields that\n * only make sense here.\n *\n * When no backend is wired, `supportsFragmentRecall` is `false` and any fragment\n * query degrades loudly ({@link FRAGMENT_SEMANTIC_UNWIRED_MESSAGE}) — it NEVER\n * returns a silent empty. A consumer-supplied backend that rejects (throws) is\n * normalized into a `Failure`.\n * @public\n */\nexport class FragmentSemanticRetriever {\n private readonly _backend: IFragmentSemanticBackend | undefined;\n\n private constructor(backend: IFragmentSemanticBackend | undefined) {\n this._backend = backend;\n }\n\n /** What this retriever can do given its wiring. */\n public get capabilities(): IFragmentRetrieverCapabilities {\n return { supportsFragmentRecall: this._backend !== undefined };\n }\n\n /** Family-convention factory. */\n public static create(params: {\n readonly backend?: IFragmentSemanticBackend;\n }): Result<FragmentSemanticRetriever> {\n return succeed(new FragmentSemanticRetriever(params.backend));\n }\n\n /**\n * Embed `query.semantic`, query the fragment index, and return the per-fragment\n * hits in descending score order. Fails loudly when no backend is wired.\n */\n public async retrieve(query: IFragmentQuery): Promise<Result<ReadonlyArray<IVectorQueryHit>>> {\n if (this._backend === undefined) {\n return fail(FRAGMENT_SEMANTIC_UNWIRED_MESSAGE);\n }\n const backend: IFragmentSemanticBackend = this._backend;\n // Consumer-supplied hooks may throw; normalize both a returned `fail` and a\n // rejection into a single `fragment recall: <label> failed` Failure so\n // `retrieve` always honors its `Promise<Result<...>>` contract.\n const embedded: Result<Float32Array> = await FragmentSemanticRetriever._callBackend(\n 'query embedding',\n () => backend.embedQuery(query.semantic)\n );\n if (embedded.isFailure()) {\n return fail(embedded.message);\n }\n return FragmentSemanticRetriever._callBackend('fragment query', () =>\n backend.fragmentIndex.query(embedded.value, query.topK ?? 10, query.maxPerRecord)\n );\n }\n\n /**\n * Invoke a consumer-supplied backend hook, normalizing both a returned `fail`\n * and a thrown/rejected promise into a single `fragment recall: <label> failed`\n * `Failure`.\n */\n private static async _callBackend<T>(label: string, op: () => Promise<Result<T>>): Promise<Result<T>> {\n try {\n return (await op()).withErrorFormat((msg) => `fragment recall: ${label} failed: ${msg}`);\n } catch (err) {\n return fail(`fragment recall: ${label} failed: ${String(err)}`);\n }\n }\n}\n"]}
1
+ {"version":3,"file":"fragmentSemanticRetriever.js","sourceRoot":"","sources":["../../../src/packlets/retrieve/fragmentSemanticRetriever.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result, captureResult, fail, succeed } from '@fgv/ts-utils';\nimport { Convert, EntityId, IIdentityCodecResult, IIdentityResolver, Kind, MemoryId } from '../types';\nimport { IFragmentQueryOptions, IFragmentVectorIndex, IVectorQueryHit } from '../vector';\nimport { QueryEmbedder } from './semanticRetriever';\n\n/**\n * The loud-degradation message a {@link FragmentSemanticRetriever} returns when a\n * fragment query is issued but no {@link IFragmentSemanticBackend | backend} is\n * wired — the discovery surface NEVER answers a fragment query with a silent empty.\n * @public\n */\nexport const FRAGMENT_SEMANTIC_UNWIRED_MESSAGE: string =\n 'fragment recall: no fragment index is wired; wire an IFragmentSemanticBackend to enable sub-document search';\n\n/**\n * The loud-degradation message returned when a query carries a record narrowing but\n * no {@link IIdentityResolver} is wired to resolve it.\n *\n * @remarks\n * Deliberately a `Failure` rather than a silently-global search: answering a scoped\n * question with an unscoped result is the failure this narrowing exists to remove.\n * @public\n */\nexport const FRAGMENT_NARROWING_UNRESOLVABLE_MESSAGE: string =\n 'fragment recall: a record narrowing was supplied but no identity resolver is wired; pass one to FragmentSemanticRetriever.create';\n\n/**\n * The message returned when exactly one of `entityId` / `kind` is supplied.\n *\n * @remarks\n * They travel together because `kind` is what selects the identity codec, and the\n * codec is what makes the resolution unambiguous. One without the other is not a\n * partial narrowing that could be honored best-effort — it is not a narrowing at all.\n * @public\n */\nexport const FRAGMENT_NARROWING_INCOMPLETE_MESSAGE: string =\n 'fragment recall: `entityId` and `kind` must be supplied together — `kind` selects the identity codec that resolves the narrowing';\n\n/**\n * The fragment backend wired into a {@link FragmentSemanticRetriever}: the fragment\n * index to query and the embedder that turns the query text into a vector. Both are\n * required together — a fragment index is useless without a way to embed the query.\n * @public\n */\nexport interface IFragmentSemanticBackend {\n /** The fragment-granular vector index to query. */\n readonly fragmentIndex: IFragmentVectorIndex;\n /** Turns the query text into a vector. */\n readonly embedQuery: QueryEmbedder;\n}\n\n/**\n * A sub-document semantic-search request: the natural-language `semantic` text to\n * match, an optional `topK` result cap (default 10), and an optional\n * `maxPerRecord` cap that keeps one long document from monopolizing the result.\n * @public\n */\nexport interface IFragmentQuery {\n /** The natural-language text to embed and match against stored fragments. */\n readonly semantic: string;\n /** Maximum number of fragment hits to return. Defaults to 10. */\n readonly topK?: number;\n /**\n * Maximum number of fragments any single record may contribute to the result.\n * Applied during selection (before the `topK` cut). Omit for uncapped.\n */\n readonly maxPerRecord?: number;\n\n /**\n * Narrow the search to one record's fragments: the consumer-supplied domain key\n * of the record to search within. **Must be supplied with\n * {@link IFragmentQuery.kind}.**\n *\n * @remarks\n * The narrowing is applied **during selection, before the `topK` cut**, so the\n * `topK` you ask for is the `topK` you get. Filtering a global result afterwards\n * is not equivalent: it truncates to `topK` across every record first, so a scoped\n * search would come back short whenever other records outscored this one's\n * fragments.\n *\n * For a versioned kind this narrows to **every version of the entity** — literally\n * every version, superseded ones included, because invalidation stamps `invalid_at`\n * without pruning that version's fragments. Nothing on a hit distinguishes a\n * current fragment from a historical one. That matches the record-granular vector\n * lane; it is not currency filtering.\n */\n readonly entityId?: EntityId;\n\n /**\n * The kind of the record named by {@link IFragmentQuery.entityId}. **Must be\n * supplied with it.**\n *\n * @remarks\n * This is not decoration and not a filter: `kind` **selects the identity codec**,\n * and the codec computes the storage address. An `EntityId` promises no uniqueness\n * beyond a scope — the same id under two kinds is the ordinary case, not a\n * pathological one — so without `kind` the resolution is ambiguous, and with it\n * ambiguity is structurally impossible.\n */\n readonly kind?: Kind;\n}\n\n/**\n * What a {@link FragmentSemanticRetriever} can do given its wiring.\n * @public\n */\nexport interface IFragmentRetrieverCapabilities {\n /** `true` when a fragment backend is wired and fragment recall is operational. */\n readonly supportsFragmentRecall: boolean;\n}\n\n/**\n * The sub-document semantic-search retriever — the \"discovery\" half of a\n * search-then-read contract. It embeds a fragment query, queries the\n * {@link IFragmentVectorIndex}, and returns the raw per-fragment\n * {@link IVectorQueryHit | hits} (each carrying a record `target` AND whichever of\n * `locator` / `fragmentId` the stored fragment was indexed with), NOT resolved\n * records: the consumer re-reads each record and resolves the fragment on its own\n * read side. Note the `locator` span is advisory — see {@link IFragmentLocator}; it\n * is not a slice guaranteed to reproduce the fragment's text.\n *\n * @remarks\n * Deliberately NOT an {@link IMemoryRetriever}: memory recall is record-granular and\n * returns records; fragment discovery is fragment-granular and returns fragment\n * identities. Keeping it a distinct surface matches the consumer contract (memory\n * stays record-granular; sub-document knowledge uses a separate fragment index) and\n * avoids overloading the record retriever's return type with identity fields that\n * only make sense here.\n *\n * When no backend is wired, `supportsFragmentRecall` is `false` and any fragment\n * query degrades loudly ({@link FRAGMENT_SEMANTIC_UNWIRED_MESSAGE}) — it NEVER\n * returns a silent empty. A consumer-supplied backend that rejects (throws) is\n * normalized into a `Failure`.\n * @public\n */\nexport class FragmentSemanticRetriever {\n private readonly _backend: IFragmentSemanticBackend | undefined;\n private readonly _identityResolver: IIdentityResolver | undefined;\n\n private constructor(\n backend: IFragmentSemanticBackend | undefined,\n identityResolver: IIdentityResolver | undefined\n ) {\n this._backend = backend;\n this._identityResolver = identityResolver;\n }\n\n /** What this retriever can do given its wiring. */\n public get capabilities(): IFragmentRetrieverCapabilities {\n return { supportsFragmentRecall: this._backend !== undefined };\n }\n\n /**\n * Family-convention factory.\n *\n * @param params - `backend` wires fragment recall itself. `identityResolver`\n * resolves a query's `(kind, entityId)` narrowing to a storage address;\n * `IMemoryStore` implements it, so the usual wiring is\n * `{ backend, identityResolver: store }`. It is optional because an unscoped\n * fragment search needs nothing to resolve — but a query that *does* carry a\n * narrowing fails loudly without it rather than quietly searching everything.\n */\n public static create(params: {\n readonly backend?: IFragmentSemanticBackend;\n readonly identityResolver?: IIdentityResolver;\n }): Result<FragmentSemanticRetriever> {\n return succeed(new FragmentSemanticRetriever(params.backend, params.identityResolver));\n }\n\n /**\n * Embed `query.semantic`, query the fragment index, and return the per-fragment\n * hits in descending score order. Fails loudly when no backend is wired.\n */\n public async retrieve(query: IFragmentQuery): Promise<Result<ReadonlyArray<IVectorQueryHit>>> {\n if (this._backend === undefined) {\n return fail(FRAGMENT_SEMANTIC_UNWIRED_MESSAGE);\n }\n const backend: IFragmentSemanticBackend = this._backend;\n // Resolve the narrowing FIRST. It is synchronous, local, and cheap, while\n // `embedQuery` is typically a paid network round trip — so a typo'd `kind`, a\n // missing resolver, or a half-supplied narrowing should cost nothing.\n const options: Result<IFragmentQueryOptions> = this._resolveOptions(query);\n if (options.isFailure()) {\n return fail(options.message);\n }\n // Consumer-supplied hooks may throw; normalize both a returned `fail` and a\n // rejection into a single `fragment recall: <label> failed` Failure so\n // `retrieve` always honors its `Promise<Result<...>>` contract.\n const embedded: Result<Float32Array> = await FragmentSemanticRetriever._callBackend(\n 'query embedding',\n () => backend.embedQuery(query.semantic)\n );\n if (embedded.isFailure()) {\n return fail(embedded.message);\n }\n return FragmentSemanticRetriever._callBackend('fragment query', () =>\n backend.fragmentIndex.query(embedded.value, query.topK ?? 10, options.value)\n );\n }\n\n /**\n * Turn the query's consumer-facing narrowing into the storage-address narrowing\n * the index understands.\n *\n * @remarks\n * `kind` selects the identity codec and the codec computes the record's storage\n * address, so this is a deterministic resolution rather than a search —\n * which is what makes a colliding `entityId` across kinds a non-issue.\n *\n * A **versioned** kind resolves to the entity's own subtree scope and deliberately\n * carries no `id`, so the narrowing covers every version of the entity — including\n * superseded ones, which are invalidated but never pruned from the index. A\n * non-versioned kind resolves to exactly one record.\n */\n private _resolveOptions(query: IFragmentQuery): Result<IFragmentQueryOptions> {\n const { entityId, kind, maxPerRecord } = query;\n if (entityId === undefined && kind === undefined) {\n return succeed({ maxPerRecord });\n }\n if (entityId === undefined || kind === undefined) {\n return fail(FRAGMENT_NARROWING_INCOMPLETE_MESSAGE);\n }\n if (this._identityResolver === undefined) {\n return fail(FRAGMENT_NARROWING_UNRESOLVABLE_MESSAGE);\n }\n const resolver: IIdentityResolver = this._identityResolver;\n // `identityResolver` is a consumer-injectable seam like the two backend hooks,\n // so a throw has to become a `Failure` here rather than escaping `retrieve()`\n // and breaking its `Promise<Result<...>>` contract. `captureResult` yields a\n // nested `Result`, which the identity `onSuccess` flattens.\n return captureResult(() => resolver.resolveIdentity(kind, entityId))\n .onSuccess((resolved: Result<IIdentityCodecResult>) => resolved)\n .withErrorFormat((msg) => `fragment recall: cannot resolve '${kind}'/'${entityId}': ${msg}`)\n .onSuccess((address: IIdentityCodecResult) => {\n // A versioned kind's every version lives under the entity subtree the\n // codec returned, so omitting `id` is what makes the narrowing mean\n // \"this entity\" rather than \"one of its versions\".\n if (address.isVersioned) {\n return succeed({ maxPerRecord, scope: address.scope });\n }\n // `idStem` is a plain `string` on the codec result, but a `MemoryId` IS the\n // filename stem by contract and `Convert.memoryId` is what enforces that —\n // so validate rather than assert. A resolver that returned a path-unsafe\n // stem could otherwise smuggle it into the index query, where it would\n // match nothing and look like an empty result rather than a caller bug.\n return Convert.memoryId\n .convert(address.idStem)\n .withErrorFormat(\n (msg) => `fragment recall: '${kind}'/'${entityId}' resolved to an unusable record id: ${msg}`\n )\n .onSuccess((id: MemoryId) => succeed({ maxPerRecord, scope: address.scope, id }));\n });\n }\n\n /**\n * Invoke a consumer-supplied backend hook, normalizing both a returned `fail`\n * and a thrown/rejected promise into a single `fragment recall: <label> failed`\n * `Failure`.\n */\n private static async _callBackend<T>(label: string, op: () => Promise<Result<T>>): Promise<Result<T>> {\n try {\n return (await op()).withErrorFormat((msg) => `fragment recall: ${label} failed: ${msg}`);\n } catch (err) {\n return fail(`fragment recall: ${label} failed: ${String(err)}`);\n }\n }\n}\n"]}
@@ -8,6 +8,7 @@ import { DEFAULT_DEDUP_SCOPE, KnowledgeLwwPolicy, isTemporalIdentityCodec, isTem
8
8
  import { parseMemoryFile, serializeMemoryFile, splitFrontmatter } from '../converters';
9
9
  import { VectorMaintenance } from './vectorMaintenance';
10
10
  import { computeCoverage } from './storeCoverage';
11
+ import { codecFor, resolveIdentity, verifyLoadedIdentity } from './storeIdentity';
11
12
  import { reconcileVectors } from './storeReconcile';
12
13
  import { MemoryIndex } from '../index';
13
14
  import { defaultMemoryScopeEncoding } from './scopeEncoding';
@@ -119,7 +120,7 @@ export class FileTreeMemoryStore {
119
120
  /** {@inheritDoc IMemoryStore.get} */
120
121
  async get(kind, entityId) {
121
122
  var _a;
122
- const result = this._codecFor(kind).onSuccess((codec) => codec.encode(entityId).onSuccess((addr) => {
123
+ const result = codecFor(this._codecs, this._defaultCodec, kind).onSuccess((codec) => codec.encode(entityId).onSuccess((addr) => {
123
124
  if (addr.isVersioned) {
124
125
  if (!isTemporalIdentityCodec(codec)) {
125
126
  return fail(`memory get '${entityId}': codec for versioned kind '${kind}' does not implement the temporal codec interface`);
@@ -225,6 +226,13 @@ export class FileTreeMemoryStore {
225
226
  resolveRecord(scope, id) {
226
227
  return this._readRecord(scope, id);
227
228
  }
229
+ _codec(kind) {
230
+ return codecFor(this._codecs, this._defaultCodec, kind);
231
+ }
232
+ /** {@inheritDoc IIdentityResolver.resolveIdentity} */
233
+ resolveIdentity(kind, entityId) {
234
+ return resolveIdentity(this._codecs, this._defaultCodec, kind, entityId);
235
+ }
228
236
  /**
229
237
  * Materialize a selected set of entries into records, dropping any that have
230
238
  * vanished since selection.
@@ -414,7 +422,7 @@ export class FileTreeMemoryStore {
414
422
  }
415
423
  /** Resolve a scope for an observation, best-effort (undefined when unresolvable). */
416
424
  _scopeBestEffort(kind, entityId) {
417
- return this._codecFor(kind)
425
+ return this._codec(kind)
418
426
  .onSuccess((codec) => codec.encode(entityId))
419
427
  .onSuccess((addr) => succeed(addr.scope))
420
428
  .orDefault();
@@ -432,7 +440,7 @@ export class FileTreeMemoryStore {
432
440
  return this._registry
433
441
  .convert(envelope.kind, body)
434
442
  .withErrorFormat((msg) => `memory put '${envelope.id}': invalid body: ${msg}`)
435
- .onSuccess(() => this._codecFor(envelope.kind))
443
+ .onSuccess(() => this._codec(envelope.kind))
436
444
  .thenOnSuccess((codec) => codec.encode(envelope.entityId).thenOnSuccess((addr) => {
437
445
  if (addr.isVersioned) {
438
446
  if (!isTemporalIdentityCodec(codec)) {
@@ -613,7 +621,7 @@ export class FileTreeMemoryStore {
613
621
  .onSuccess(() => succeed(record));
614
622
  }
615
623
  async _deleteLocked(kind, entityId) {
616
- return this._codecFor(kind).thenOnSuccess((codec) => codec.encode(entityId).thenOnSuccess((addr) => {
624
+ return this._codec(kind).thenOnSuccess((codec) => codec.encode(entityId).thenOnSuccess((addr) => {
617
625
  if (addr.isVersioned) {
618
626
  if (!isTemporalIdentityCodec(codec)) {
619
627
  return Promise.resolve(fail(`memory delete '${entityId}': codec for versioned kind '${kind}' does not implement the temporal codec interface`));
@@ -1074,14 +1082,6 @@ export class FileTreeMemoryStore {
1074
1082
  return { envelope: Object.assign(Object.assign({}, record.envelope), { rank: undefined }), body: record.body };
1075
1083
  }
1076
1084
  }
1077
- _codecFor(kind) {
1078
- var _a;
1079
- const codec = (_a = this._codecs.get(kind)) !== null && _a !== void 0 ? _a : this._defaultCodec;
1080
- if (codec === undefined) {
1081
- return fail(`no identity codec registered for kind '${kind}'`);
1082
- }
1083
- return succeed(codec);
1084
- }
1085
1085
  _policyFor(kind) {
1086
1086
  var _a;
1087
1087
  return (_a = this._writePolicies.get(kind)) !== null && _a !== void 0 ? _a : this._defaultPolicy;
@@ -1130,18 +1130,7 @@ export class FileTreeMemoryStore {
1130
1130
  * verbatim downstream (e.g. merge-into re-addressing). Cross-check it here.
1131
1131
  */
1132
1132
  _verifyLoaded(scope, file, record) {
1133
- if (record.envelope.id !== file.baseName) {
1134
- return fail(`memory file '${file.absolutePath}': envelope id '${record.envelope.id}' does not match filename stem '${file.baseName}'`);
1135
- }
1136
- return this._codecFor(record.envelope.kind)
1137
- .onSuccess((codec) => codec.verifyRoundTrip(scope, file.baseName).onSuccess(() => codec.decode(scope, file.baseName)))
1138
- .withErrorFormat((msg) => `memory file '${file.absolutePath}': ${msg}`)
1139
- .onSuccess((decodedEntityId) => {
1140
- if (decodedEntityId !== record.envelope.entityId) {
1141
- return fail(`memory file '${file.absolutePath}': envelope entityId '${record.envelope.entityId}' does not match scope-derived entityId '${decodedEntityId}'`);
1142
- }
1143
- return succeed(record);
1144
- });
1133
+ return verifyLoadedIdentity(this._codec(record.envelope.kind), scope, file, record);
1145
1134
  }
1146
1135
  /**
1147
1136
  * Resolve the directory for a scope, returning `undefined` when it does not