@fgv/ts-agent-memory 5.1.0-49 → 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 (182) hide show
  1. package/dist/packlets/index/memoryIndex.js +23 -16
  2. package/dist/packlets/index/memoryIndex.js.map +1 -1
  3. package/dist/packlets/ingest/orchestrator.js +13 -1
  4. package/dist/packlets/ingest/orchestrator.js.map +1 -1
  5. package/dist/packlets/retrieve/fragmentSemanticRetriever.js +94 -5
  6. package/dist/packlets/retrieve/fragmentSemanticRetriever.js.map +1 -1
  7. package/dist/packlets/retrieve/linkTraversalRetriever.js +12 -26
  8. package/dist/packlets/retrieve/linkTraversalRetriever.js.map +1 -1
  9. package/dist/packlets/retrieve/recencyRetriever.js +7 -7
  10. package/dist/packlets/retrieve/recencyRetriever.js.map +1 -1
  11. package/dist/packlets/retrieve/retriever.js +91 -10
  12. package/dist/packlets/retrieve/retriever.js.map +1 -1
  13. package/dist/packlets/retrieve/semanticRetriever.js +16 -16
  14. package/dist/packlets/retrieve/semanticRetriever.js.map +1 -1
  15. package/dist/packlets/retrieve/structuredFilterRetriever.js +7 -7
  16. package/dist/packlets/retrieve/structuredFilterRetriever.js.map +1 -1
  17. package/dist/packlets/retrieve/tagRetriever.js +7 -7
  18. package/dist/packlets/retrieve/tagRetriever.js.map +1 -1
  19. package/dist/packlets/retrieve/temporalRetrievers.js +23 -20
  20. package/dist/packlets/retrieve/temporalRetrievers.js.map +1 -1
  21. package/dist/packlets/store/coverage.js +6 -0
  22. package/dist/packlets/store/coverage.js.map +1 -0
  23. package/dist/packlets/store/fileTreeMemoryStore.js +234 -103
  24. package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
  25. package/dist/packlets/store/index.js +4 -0
  26. package/dist/packlets/store/index.js.map +1 -1
  27. package/dist/packlets/store/listSelection.js +36 -0
  28. package/dist/packlets/store/listSelection.js.map +1 -0
  29. package/dist/packlets/store/memoryStore.js +6 -0
  30. package/dist/packlets/store/memoryStore.js.map +1 -0
  31. package/dist/packlets/store/reconcile.js +6 -0
  32. package/dist/packlets/store/reconcile.js.map +1 -0
  33. package/dist/packlets/store/storeCoverage.js +102 -0
  34. package/dist/packlets/store/storeCoverage.js.map +1 -0
  35. package/dist/packlets/store/storeIdentity.js +62 -0
  36. package/dist/packlets/store/storeIdentity.js.map +1 -0
  37. package/dist/packlets/store/storeReconcile.js +122 -0
  38. package/dist/packlets/store/storeReconcile.js.map +1 -0
  39. package/dist/packlets/store/vectorMaintenance.js +116 -8
  40. package/dist/packlets/store/vectorMaintenance.js.map +1 -1
  41. package/dist/packlets/store/vectorRecordSource.js +44 -0
  42. package/dist/packlets/store/vectorRecordSource.js.map +1 -0
  43. package/dist/packlets/tools/memoryTools.js +25 -2
  44. package/dist/packlets/tools/memoryTools.js.map +1 -1
  45. package/dist/packlets/types/envelope.js +25 -0
  46. package/dist/packlets/types/envelope.js.map +1 -1
  47. package/dist/packlets/types/identityResolver.js +6 -0
  48. package/dist/packlets/types/identityResolver.js.map +1 -0
  49. package/dist/packlets/types/index.js +2 -0
  50. package/dist/packlets/types/index.js.map +1 -1
  51. package/dist/packlets/types/recordResolver.js +6 -0
  52. package/dist/packlets/types/recordResolver.js.map +1 -0
  53. package/dist/packlets/types/temporal.js.map +1 -1
  54. package/dist/packlets/vector/inMemoryCosineIndex.js +39 -18
  55. package/dist/packlets/vector/inMemoryCosineIndex.js.map +1 -1
  56. package/dist/packlets/vector/inMemoryFragmentCosineIndex.js +109 -14
  57. package/dist/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -1
  58. package/dist/packlets/vector/rebuildHelpers.js +38 -0
  59. package/dist/packlets/vector/rebuildHelpers.js.map +1 -0
  60. package/dist/packlets/vector/vectorIndex.js.map +1 -1
  61. package/dist/ts-agent-memory.d.ts +1226 -114
  62. package/lib/packlets/index/memoryIndex.d.ts +118 -27
  63. package/lib/packlets/index/memoryIndex.d.ts.map +1 -1
  64. package/lib/packlets/index/memoryIndex.js +23 -16
  65. package/lib/packlets/index/memoryIndex.js.map +1 -1
  66. package/lib/packlets/ingest/orchestrator.d.ts.map +1 -1
  67. package/lib/packlets/ingest/orchestrator.js +13 -1
  68. package/lib/packlets/ingest/orchestrator.js.map +1 -1
  69. package/lib/packlets/retrieve/fragmentSemanticRetriever.d.ts +79 -1
  70. package/lib/packlets/retrieve/fragmentSemanticRetriever.d.ts.map +1 -1
  71. package/lib/packlets/retrieve/fragmentSemanticRetriever.js +94 -5
  72. package/lib/packlets/retrieve/fragmentSemanticRetriever.js.map +1 -1
  73. package/lib/packlets/retrieve/linkTraversalRetriever.d.ts +3 -10
  74. package/lib/packlets/retrieve/linkTraversalRetriever.d.ts.map +1 -1
  75. package/lib/packlets/retrieve/linkTraversalRetriever.js +11 -25
  76. package/lib/packlets/retrieve/linkTraversalRetriever.js.map +1 -1
  77. package/lib/packlets/retrieve/recencyRetriever.d.ts +3 -3
  78. package/lib/packlets/retrieve/recencyRetriever.d.ts.map +1 -1
  79. package/lib/packlets/retrieve/recencyRetriever.js +6 -6
  80. package/lib/packlets/retrieve/recencyRetriever.js.map +1 -1
  81. package/lib/packlets/retrieve/retriever.d.ts +88 -7
  82. package/lib/packlets/retrieve/retriever.d.ts.map +1 -1
  83. package/lib/packlets/retrieve/retriever.js +94 -9
  84. package/lib/packlets/retrieve/retriever.js.map +1 -1
  85. package/lib/packlets/retrieve/semanticRetriever.d.ts +3 -5
  86. package/lib/packlets/retrieve/semanticRetriever.d.ts.map +1 -1
  87. package/lib/packlets/retrieve/semanticRetriever.js +15 -15
  88. package/lib/packlets/retrieve/semanticRetriever.js.map +1 -1
  89. package/lib/packlets/retrieve/structuredFilterRetriever.d.ts +3 -3
  90. package/lib/packlets/retrieve/structuredFilterRetriever.d.ts.map +1 -1
  91. package/lib/packlets/retrieve/structuredFilterRetriever.js +6 -6
  92. package/lib/packlets/retrieve/structuredFilterRetriever.js.map +1 -1
  93. package/lib/packlets/retrieve/tagRetriever.d.ts +3 -3
  94. package/lib/packlets/retrieve/tagRetriever.d.ts.map +1 -1
  95. package/lib/packlets/retrieve/tagRetriever.js +6 -6
  96. package/lib/packlets/retrieve/tagRetriever.js.map +1 -1
  97. package/lib/packlets/retrieve/temporalRetrievers.d.ts +7 -5
  98. package/lib/packlets/retrieve/temporalRetrievers.d.ts.map +1 -1
  99. package/lib/packlets/retrieve/temporalRetrievers.js +22 -19
  100. package/lib/packlets/retrieve/temporalRetrievers.js.map +1 -1
  101. package/lib/packlets/store/coverage.d.ts +102 -0
  102. package/lib/packlets/store/coverage.d.ts.map +1 -0
  103. package/lib/packlets/store/coverage.js +7 -0
  104. package/lib/packlets/store/coverage.js.map +1 -0
  105. package/lib/packlets/store/fileTreeMemoryStore.d.ts +56 -167
  106. package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
  107. package/lib/packlets/store/fileTreeMemoryStore.js +234 -103
  108. package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
  109. package/lib/packlets/store/index.d.ts +4 -0
  110. package/lib/packlets/store/index.d.ts.map +1 -1
  111. package/lib/packlets/store/index.js +4 -0
  112. package/lib/packlets/store/index.js.map +1 -1
  113. package/lib/packlets/store/listSelection.d.ts +101 -0
  114. package/lib/packlets/store/listSelection.d.ts.map +1 -0
  115. package/lib/packlets/store/listSelection.js +40 -0
  116. package/lib/packlets/store/listSelection.js.map +1 -0
  117. package/lib/packlets/store/memoryStore.d.ts +237 -0
  118. package/lib/packlets/store/memoryStore.d.ts.map +1 -0
  119. package/lib/packlets/store/memoryStore.js +7 -0
  120. package/lib/packlets/store/memoryStore.js.map +1 -0
  121. package/lib/packlets/store/reconcile.d.ts +82 -0
  122. package/lib/packlets/store/reconcile.d.ts.map +1 -0
  123. package/lib/packlets/store/reconcile.js +7 -0
  124. package/lib/packlets/store/reconcile.js.map +1 -0
  125. package/lib/packlets/store/storeCoverage.d.ts +45 -0
  126. package/lib/packlets/store/storeCoverage.d.ts.map +1 -0
  127. package/lib/packlets/store/storeCoverage.js +105 -0
  128. package/lib/packlets/store/storeCoverage.js.map +1 -0
  129. package/lib/packlets/store/storeIdentity.d.ts +38 -0
  130. package/lib/packlets/store/storeIdentity.d.ts.map +1 -0
  131. package/lib/packlets/store/storeIdentity.js +67 -0
  132. package/lib/packlets/store/storeIdentity.js.map +1 -0
  133. package/lib/packlets/store/storeReconcile.d.ts +41 -0
  134. package/lib/packlets/store/storeReconcile.d.ts.map +1 -0
  135. package/lib/packlets/store/storeReconcile.js +125 -0
  136. package/lib/packlets/store/storeReconcile.js.map +1 -0
  137. package/lib/packlets/store/vectorMaintenance.d.ts +74 -0
  138. package/lib/packlets/store/vectorMaintenance.d.ts.map +1 -1
  139. package/lib/packlets/store/vectorMaintenance.js +117 -8
  140. package/lib/packlets/store/vectorMaintenance.js.map +1 -1
  141. package/lib/packlets/store/vectorRecordSource.d.ts +36 -0
  142. package/lib/packlets/store/vectorRecordSource.d.ts.map +1 -0
  143. package/lib/packlets/store/vectorRecordSource.js +47 -0
  144. package/lib/packlets/store/vectorRecordSource.js.map +1 -0
  145. package/lib/packlets/tools/memoryTools.d.ts.map +1 -1
  146. package/lib/packlets/tools/memoryTools.js +25 -2
  147. package/lib/packlets/tools/memoryTools.js.map +1 -1
  148. package/lib/packlets/types/envelope.d.ts +24 -2
  149. package/lib/packlets/types/envelope.d.ts.map +1 -1
  150. package/lib/packlets/types/envelope.js +26 -0
  151. package/lib/packlets/types/envelope.js.map +1 -1
  152. package/lib/packlets/types/identityResolver.d.ts +42 -0
  153. package/lib/packlets/types/identityResolver.d.ts.map +1 -0
  154. package/lib/packlets/types/identityResolver.js +7 -0
  155. package/lib/packlets/types/identityResolver.js.map +1 -0
  156. package/lib/packlets/types/index.d.ts +2 -0
  157. package/lib/packlets/types/index.d.ts.map +1 -1
  158. package/lib/packlets/types/index.js +2 -0
  159. package/lib/packlets/types/index.js.map +1 -1
  160. package/lib/packlets/types/recordResolver.d.ts +39 -0
  161. package/lib/packlets/types/recordResolver.d.ts.map +1 -0
  162. package/lib/packlets/types/recordResolver.js +7 -0
  163. package/lib/packlets/types/recordResolver.js.map +1 -0
  164. package/lib/packlets/types/temporal.d.ts +26 -6
  165. package/lib/packlets/types/temporal.d.ts.map +1 -1
  166. package/lib/packlets/types/temporal.js.map +1 -1
  167. package/lib/packlets/vector/inMemoryCosineIndex.d.ts +9 -2
  168. package/lib/packlets/vector/inMemoryCosineIndex.d.ts.map +1 -1
  169. package/lib/packlets/vector/inMemoryCosineIndex.js +40 -19
  170. package/lib/packlets/vector/inMemoryCosineIndex.js.map +1 -1
  171. package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts +18 -4
  172. package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts.map +1 -1
  173. package/lib/packlets/vector/inMemoryFragmentCosineIndex.js +108 -13
  174. package/lib/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -1
  175. package/lib/packlets/vector/rebuildHelpers.d.ts +30 -0
  176. package/lib/packlets/vector/rebuildHelpers.d.ts.map +1 -0
  177. package/lib/packlets/vector/rebuildHelpers.js +42 -0
  178. package/lib/packlets/vector/rebuildHelpers.js.map +1 -0
  179. package/lib/packlets/vector/vectorIndex.d.ts +326 -20
  180. package/lib/packlets/vector/vectorIndex.d.ts.map +1 -1
  181. package/lib/packlets/vector/vectorIndex.js.map +1 -1
  182. 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"]}
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { fail, succeed } from '@fgv/ts-utils';
6
6
  import { edgeTargetKey } from '../types';
7
- import { guardRetrieverCapabilities, indexedRecordMatchesQuery, limitRecords, orderingCompare } from './retriever';
7
+ import { guardRetrieverCapabilities, indexedRecordMatchesQuery, materializePage, orderingCompare } from './retriever';
8
8
  /** The capabilities a link-traversal retriever exposes (link traversal only). */
9
9
  const LINK_TRAVERSAL_CAPABILITIES = {
10
10
  supportsSemanticRecall: false,
@@ -42,12 +42,13 @@ export const LINK_TRAVERSAL_NO_SEED_MESSAGE = 'link traversal requires a seed id
42
42
  * @public
43
43
  */
44
44
  export class LinkTraversalRetriever {
45
- constructor(index) {
46
- this._index = index;
45
+ constructor(params) {
46
+ this._index = params.index;
47
+ this._resolver = params.resolver;
47
48
  }
48
49
  /** Family-convention factory. */
49
- static create(index) {
50
- return succeed(new LinkTraversalRetriever(index));
50
+ static create(params) {
51
+ return succeed(new LinkTraversalRetriever(params));
51
52
  }
52
53
  /** {@inheritDoc IMemoryRetriever.capabilities} */
53
54
  get capabilities() {
@@ -66,7 +67,6 @@ export class LinkTraversalRetriever {
66
67
  return fail(LINK_TRAVERSAL_NO_SEED_MESSAGE);
67
68
  }
68
69
  const hops = (_b = query.hops) !== null && _b !== void 0 ? _b : DEFAULT_HOPS;
69
- const byKey = this._indexByKey();
70
70
  // The visited-set IS the cycle guard: nodes are canonicalized to their
71
71
  // `(scope, id)` string, so set membership is an exact identity check. The
72
72
  // seed is pre-marked so it is never re-added.
@@ -76,7 +76,7 @@ export class LinkTraversalRetriever {
76
76
  for (let hop = 0; hop < hops && frontier.length > 0; hop++) {
77
77
  const next = [];
78
78
  for (const node of frontier) {
79
- for (const neighbor of outbound ? this._outbound(node, byKey) : this._inbound(node)) {
79
+ for (const neighbor of outbound ? this._outbound(node) : this._inbound(node)) {
80
80
  const neighborKey = edgeTargetKey(neighbor);
81
81
  if (!visited.has(neighborKey)) {
82
82
  visited.add(neighborKey);
@@ -89,36 +89,22 @@ export class LinkTraversalRetriever {
89
89
  }
90
90
  const entries = [];
91
91
  for (const node of reached) {
92
- const match = byKey.get(edgeTargetKey(node));
92
+ const match = this._index.get(node);
93
93
  if (match !== undefined) {
94
94
  entries.push(match);
95
95
  }
96
96
  }
97
97
  const ordered = entries
98
98
  .filter((entry) => indexedRecordMatchesQuery(entry, query))
99
- .map((entry) => entry.record)
100
99
  .sort(orderingCompare(query.orderBy));
101
- return succeed(limitRecords(ordered, query.limit, query.offset));
102
- }
103
- /**
104
- * Group the index's entries by their scope-qualified {@link edgeTargetKey}
105
- * `(scope, id)` composite. Each composite is the index's primary key, so it maps
106
- * to exactly one entry — two records that reuse a filename stem across scopes
107
- * (e.g. `turn-0` in two conversations) get distinct keys and never collide.
108
- */
109
- _indexByKey() {
110
- const byKey = new Map();
111
- for (const entry of this._index.entries()) {
112
- byKey.set(edgeTargetKey({ scope: entry.scope, id: entry.record.envelope.id }), entry);
113
- }
114
- return byKey;
100
+ return materializePage(ordered, query, this._resolver);
115
101
  }
116
102
  /** Outbound neighbors: the scope-qualified targets of every edge on the record at `node`. */
117
- _outbound(node, byKey) {
103
+ _outbound(node) {
118
104
  const targets = [];
119
- const match = byKey.get(edgeTargetKey(node));
105
+ const match = this._index.get(node);
120
106
  if (match !== undefined) {
121
- for (const edge of match.record.envelope.links) {
107
+ for (const edge of match.envelope.links) {
122
108
  targets.push(edge.target);
123
109
  }
124
110
  }
@@ -1 +1 @@
1
- {"version":3,"file":"linkTraversalRetriever.js","sourceRoot":"","sources":["../../../src/packlets/retrieve/linkTraversalRetriever.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result, fail, succeed } from '@fgv/ts-utils';\nimport { IEdgeTarget, IMemoryRecord, edgeTargetKey } from '../types';\nimport { IIndexedMemoryRecord, IMemoryIndex } from '../index';\nimport {\n IMemoryQuery,\n IMemoryRetriever,\n IMemoryRetrieverCapabilities,\n guardRetrieverCapabilities,\n indexedRecordMatchesQuery,\n limitRecords,\n orderingCompare\n} from './retriever';\n\n/** The capabilities a link-traversal retriever exposes (link traversal only). */\nconst LINK_TRAVERSAL_CAPABILITIES: IMemoryRetrieverCapabilities = {\n supportsSemanticRecall: false,\n supportsTemporalQuery: false,\n supportsLinkTraversal: true\n};\n\n/** Default BFS hop count when `query.hops` is not supplied. */\nconst DEFAULT_HOPS: number = 1;\n\n/**\n * The loud-degradation message returned when a link-traversal query supplies no\n * seed (`linkedFrom` / `linkedTo`).\n * @public\n */\nexport const LINK_TRAVERSAL_NO_SEED_MESSAGE: string =\n 'link traversal requires a seed id (linkedFrom or linkedTo)';\n\n/**\n * Breadth-first link-traversal retriever. From a scope-qualified\n * {@link IEdgeTarget} seed it walks the link graph up to `query.hops` levels and\n * returns the records reached (excluding the seed), recency-ordered and limited.\n *\n * @remarks\n * - **Direction.** `linkedFrom` walks OUTBOUND edges (each record's\n * `envelope.links[].target`); `linkedTo` walks INBOUND edges (the index's\n * `backlinks`). Exactly one is the seed; `linkedFrom` wins if both are set.\n * - **Scope-qualified nodes.** Every graph node is an {@link IEdgeTarget}\n * `(scope, id)` pair, so following an edge to `turn-3` reaches ONLY the record\n * in the edge's own scope — never a same-stem record in another scope.\n * - **Bound + cycle safety.** Traversal is bounded by `hops` (default `1` — a\n * single hop) and a visited-set guard. Nodes are canonicalized to their\n * `(scope, id)` string via {@link edgeTargetKey}, so a `Set<string>` visited-set\n * is the exact, collision-free cycle key — no structural hashing (e.g.\n * `Crc32Normalizer`) is needed. A self-loop or any multi-hop cycle terminates\n * because a revisited node is never re-expanded.\n * - **Post-filter.** The scope / kind / tag / provenance-source / predicate axes of the query are\n * applied to the reached records (the link axes are the traversal itself).\n * @public\n */\nexport class LinkTraversalRetriever implements IMemoryRetriever {\n private readonly _index: IMemoryIndex;\n\n private constructor(index: IMemoryIndex) {\n this._index = index;\n }\n\n /** Family-convention factory. */\n public static create(index: IMemoryIndex): Result<LinkTraversalRetriever> {\n return succeed(new LinkTraversalRetriever(index));\n }\n\n /** {@inheritDoc IMemoryRetriever.capabilities} */\n public get capabilities(): IMemoryRetrieverCapabilities {\n return LINK_TRAVERSAL_CAPABILITIES;\n }\n\n /** {@inheritDoc IMemoryRetriever.retrieve} */\n public retrieve(query: IMemoryQuery): Promise<Result<ReadonlyArray<IMemoryRecord<unknown>>>> {\n return Promise.resolve(\n guardRetrieverCapabilities(query, this.capabilities).onSuccess(() => this._traverse(query))\n );\n }\n\n /** Run the bounded, cycle-safe BFS and post-filter the reached records. */\n private _traverse(query: IMemoryQuery): Result<ReadonlyArray<IMemoryRecord<unknown>>> {\n const outbound: boolean = query.linkedFrom !== undefined;\n const seed: IEdgeTarget | undefined = query.linkedFrom ?? query.linkedTo;\n if (seed === undefined) {\n return fail(LINK_TRAVERSAL_NO_SEED_MESSAGE);\n }\n const hops: number = query.hops ?? DEFAULT_HOPS;\n const byKey: ReadonlyMap<string, IIndexedMemoryRecord> = this._indexByKey();\n\n // The visited-set IS the cycle guard: nodes are canonicalized to their\n // `(scope, id)` string, so set membership is an exact identity check. The\n // seed is pre-marked so it is never re-added.\n const visited: Set<string> = new Set<string>([edgeTargetKey(seed)]);\n const reached: IEdgeTarget[] = [];\n let frontier: IEdgeTarget[] = [seed];\n for (let hop = 0; hop < hops && frontier.length > 0; hop++) {\n const next: IEdgeTarget[] = [];\n for (const node of frontier) {\n for (const neighbor of outbound ? this._outbound(node, byKey) : this._inbound(node)) {\n const neighborKey: string = edgeTargetKey(neighbor);\n if (!visited.has(neighborKey)) {\n visited.add(neighborKey);\n reached.push(neighbor);\n next.push(neighbor);\n }\n }\n }\n frontier = next;\n }\n\n const entries: IIndexedMemoryRecord[] = [];\n for (const node of reached) {\n const match: IIndexedMemoryRecord | undefined = byKey.get(edgeTargetKey(node));\n if (match !== undefined) {\n entries.push(match);\n }\n }\n const ordered: IMemoryRecord<unknown>[] = entries\n .filter((entry) => indexedRecordMatchesQuery(entry, query))\n .map((entry) => entry.record)\n .sort(orderingCompare(query.orderBy));\n return succeed(limitRecords(ordered, query.limit, query.offset));\n }\n\n /**\n * Group the index's entries by their scope-qualified {@link edgeTargetKey}\n * `(scope, id)` composite. Each composite is the index's primary key, so it maps\n * to exactly one entry — two records that reuse a filename stem across scopes\n * (e.g. `turn-0` in two conversations) get distinct keys and never collide.\n */\n private _indexByKey(): ReadonlyMap<string, IIndexedMemoryRecord> {\n const byKey: Map<string, IIndexedMemoryRecord> = new Map<string, IIndexedMemoryRecord>();\n for (const entry of this._index.entries()) {\n byKey.set(edgeTargetKey({ scope: entry.scope, id: entry.record.envelope.id }), entry);\n }\n return byKey;\n }\n\n /** Outbound neighbors: the scope-qualified targets of every edge on the record at `node`. */\n private _outbound(node: IEdgeTarget, byKey: ReadonlyMap<string, IIndexedMemoryRecord>): IEdgeTarget[] {\n const targets: IEdgeTarget[] = [];\n const match: IIndexedMemoryRecord | undefined = byKey.get(edgeTargetKey(node));\n if (match !== undefined) {\n for (const edge of match.record.envelope.links) {\n targets.push(edge.target);\n }\n }\n return targets;\n }\n\n /** Inbound neighbors: the scope-qualified sources whose edges point AT `node` (the backlinks). */\n private _inbound(node: IEdgeTarget): ReadonlyArray<IEdgeTarget> {\n return this._index.backlinks(node);\n }\n}\n"]}
1
+ {"version":3,"file":"linkTraversalRetriever.js","sourceRoot":"","sources":["../../../src/packlets/retrieve/linkTraversalRetriever.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result, fail, succeed } from '@fgv/ts-utils';\nimport { IEdgeTarget, IMemoryRecord, IMemoryRecordResolver, edgeTargetKey } from '../types';\nimport { IIndexedMemoryEntry, IMemoryIndex } from '../index';\nimport {\n IMemoryQuery,\n IMemoryRetriever,\n IMemoryRetrieverCapabilities,\n IRetrieverCreateParams,\n guardRetrieverCapabilities,\n indexedRecordMatchesQuery,\n materializePage,\n orderingCompare\n} from './retriever';\n\n/** The capabilities a link-traversal retriever exposes (link traversal only). */\nconst LINK_TRAVERSAL_CAPABILITIES: IMemoryRetrieverCapabilities = {\n supportsSemanticRecall: false,\n supportsTemporalQuery: false,\n supportsLinkTraversal: true\n};\n\n/** Default BFS hop count when `query.hops` is not supplied. */\nconst DEFAULT_HOPS: number = 1;\n\n/**\n * The loud-degradation message returned when a link-traversal query supplies no\n * seed (`linkedFrom` / `linkedTo`).\n * @public\n */\nexport const LINK_TRAVERSAL_NO_SEED_MESSAGE: string =\n 'link traversal requires a seed id (linkedFrom or linkedTo)';\n\n/**\n * Breadth-first link-traversal retriever. From a scope-qualified\n * {@link IEdgeTarget} seed it walks the link graph up to `query.hops` levels and\n * returns the records reached (excluding the seed), recency-ordered and limited.\n *\n * @remarks\n * - **Direction.** `linkedFrom` walks OUTBOUND edges (each record's\n * `envelope.links[].target`); `linkedTo` walks INBOUND edges (the index's\n * `backlinks`). Exactly one is the seed; `linkedFrom` wins if both are set.\n * - **Scope-qualified nodes.** Every graph node is an {@link IEdgeTarget}\n * `(scope, id)` pair, so following an edge to `turn-3` reaches ONLY the record\n * in the edge's own scope — never a same-stem record in another scope.\n * - **Bound + cycle safety.** Traversal is bounded by `hops` (default `1` — a\n * single hop) and a visited-set guard. Nodes are canonicalized to their\n * `(scope, id)` string via {@link edgeTargetKey}, so a `Set<string>` visited-set\n * is the exact, collision-free cycle key — no structural hashing (e.g.\n * `Crc32Normalizer`) is needed. A self-loop or any multi-hop cycle terminates\n * because a revisited node is never re-expanded.\n * - **Post-filter.** The scope / kind / tag / provenance-source / predicate axes of the query are\n * applied to the reached records (the link axes are the traversal itself).\n * @public\n */\nexport class LinkTraversalRetriever implements IMemoryRetriever {\n private readonly _index: IMemoryIndex;\n private readonly _resolver: IMemoryRecordResolver;\n\n private constructor(params: IRetrieverCreateParams) {\n this._index = params.index;\n this._resolver = params.resolver;\n }\n\n /** Family-convention factory. */\n public static create(params: IRetrieverCreateParams): Result<LinkTraversalRetriever> {\n return succeed(new LinkTraversalRetriever(params));\n }\n\n /** {@inheritDoc IMemoryRetriever.capabilities} */\n public get capabilities(): IMemoryRetrieverCapabilities {\n return LINK_TRAVERSAL_CAPABILITIES;\n }\n\n /** {@inheritDoc IMemoryRetriever.retrieve} */\n public retrieve(query: IMemoryQuery): Promise<Result<ReadonlyArray<IMemoryRecord<unknown>>>> {\n return Promise.resolve(\n guardRetrieverCapabilities(query, this.capabilities).onSuccess(() => this._traverse(query))\n );\n }\n\n /** Run the bounded, cycle-safe BFS and post-filter the reached records. */\n private _traverse(query: IMemoryQuery): Result<ReadonlyArray<IMemoryRecord<unknown>>> {\n const outbound: boolean = query.linkedFrom !== undefined;\n const seed: IEdgeTarget | undefined = query.linkedFrom ?? query.linkedTo;\n if (seed === undefined) {\n return fail(LINK_TRAVERSAL_NO_SEED_MESSAGE);\n }\n const hops: number = query.hops ?? DEFAULT_HOPS;\n\n // The visited-set IS the cycle guard: nodes are canonicalized to their\n // `(scope, id)` string, so set membership is an exact identity check. The\n // seed is pre-marked so it is never re-added.\n const visited: Set<string> = new Set<string>([edgeTargetKey(seed)]);\n const reached: IEdgeTarget[] = [];\n let frontier: IEdgeTarget[] = [seed];\n for (let hop = 0; hop < hops && frontier.length > 0; hop++) {\n const next: IEdgeTarget[] = [];\n for (const node of frontier) {\n for (const neighbor of outbound ? this._outbound(node) : this._inbound(node)) {\n const neighborKey: string = edgeTargetKey(neighbor);\n if (!visited.has(neighborKey)) {\n visited.add(neighborKey);\n reached.push(neighbor);\n next.push(neighbor);\n }\n }\n }\n frontier = next;\n }\n\n const entries: IIndexedMemoryEntry[] = [];\n for (const node of reached) {\n const match: IIndexedMemoryEntry | undefined = this._index.get(node);\n if (match !== undefined) {\n entries.push(match);\n }\n }\n const ordered: ReadonlyArray<IIndexedMemoryEntry> = entries\n .filter((entry) => indexedRecordMatchesQuery(entry, query))\n .sort(orderingCompare(query.orderBy));\n return materializePage(ordered, query, this._resolver);\n }\n\n /** Outbound neighbors: the scope-qualified targets of every edge on the record at `node`. */\n private _outbound(node: IEdgeTarget): IEdgeTarget[] {\n const targets: IEdgeTarget[] = [];\n const match: IIndexedMemoryEntry | undefined = this._index.get(node);\n if (match !== undefined) {\n for (const edge of match.envelope.links) {\n targets.push(edge.target);\n }\n }\n return targets;\n }\n\n /** Inbound neighbors: the scope-qualified sources whose edges point AT `node` (the backlinks). */\n private _inbound(node: IEdgeTarget): ReadonlyArray<IEdgeTarget> {\n return this._index.backlinks(node);\n }\n}\n"]}
@@ -3,7 +3,7 @@
3
3
  * SPDX-License-Identifier: MIT
4
4
  */
5
5
  import { succeed } from '@fgv/ts-utils';
6
- import { NON_SEMANTIC_CAPABILITIES, guardRetrieverCapabilities, limitRecords, orderingCompare, selectByQuery } from './retriever';
6
+ import { NON_SEMANTIC_CAPABILITIES, guardRetrieverCapabilities, resolveQuery } from './retriever';
7
7
  /**
8
8
  * Returns records matching the query's scope / kind / tag / provenance-source / predicate filters,
9
9
  * ordered most-recently-updated first. The universal v1 retriever — an empty
@@ -11,22 +11,22 @@ import { NON_SEMANTIC_CAPABILITIES, guardRetrieverCapabilities, limitRecords, or
11
11
  * @public
12
12
  */
13
13
  export class RecencyRetriever {
14
- constructor(index) {
15
- this._index = index;
14
+ constructor(params) {
15
+ this._index = params.index;
16
+ this._resolver = params.resolver;
16
17
  }
17
18
  /** {@inheritDoc IMemoryRetriever.capabilities} */
18
19
  get capabilities() {
19
20
  return NON_SEMANTIC_CAPABILITIES;
20
21
  }
21
22
  /** Family-convention factory. */
22
- static create(index) {
23
- return succeed(new RecencyRetriever(index));
23
+ static create(params) {
24
+ return succeed(new RecencyRetriever(params));
24
25
  }
25
26
  /** {@inheritDoc IMemoryRetriever.retrieve} */
26
27
  retrieve(query) {
27
28
  return Promise.resolve(guardRetrieverCapabilities(query, this.capabilities).onSuccess(() => {
28
- const ordered = selectByQuery(this._index.entries(), query).sort(orderingCompare(query.orderBy));
29
- return succeed(limitRecords(ordered, query.limit, query.offset));
29
+ return resolveQuery(this._index.entries(), query, this._resolver);
30
30
  }));
31
31
  }
32
32
  }
@@ -1 +1 @@
1
- {"version":3,"file":"recencyRetriever.js","sourceRoot":"","sources":["../../../src/packlets/retrieve/recencyRetriever.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAU,OAAO,EAAE,MAAM,eAAe,CAAC;AAGhD,OAAO,EAIL,yBAAyB,EACzB,0BAA0B,EAC1B,YAAY,EACZ,eAAe,EACf,aAAa,EACd,MAAM,aAAa,CAAC;AAErB;;;;;GAKG;AACH,MAAM,OAAO,gBAAgB;IAG3B,YAAoB,KAAmB;QACrC,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC;IACtB,CAAC;IAED,kDAAkD;IAClD,IAAW,YAAY;QACrB,OAAO,yBAAyB,CAAC;IACnC,CAAC;IAED,iCAAiC;IAC1B,MAAM,CAAC,MAAM,CAAC,KAAmB;QACtC,OAAO,OAAO,CAAC,IAAI,gBAAgB,CAAC,KAAK,CAAC,CAAC,CAAC;IAC9C,CAAC;IAED,8CAA8C;IACvC,QAAQ,CAAC,KAAmB;QACjC,OAAO,OAAO,CAAC,OAAO,CACpB,0BAA0B,CAAC,KAAK,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC,SAAS,CAAC,GAAG,EAAE;YAClE,MAAM,OAAO,GAA6B,aAAa,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,EAAE,KAAK,CAAC,CAAC,IAAI,CACxF,eAAe,CAAC,KAAK,CAAC,OAAO,CAAC,CAC/B,CAAC;YACF,OAAO,OAAO,CAAC,YAAY,CAAC,OAAO,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC;QACnE,CAAC,CAAC,CACH,CAAC;IACJ,CAAC;CACF","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result, succeed } from '@fgv/ts-utils';\nimport { IMemoryRecord } from '../types';\nimport { IMemoryIndex } from '../index';\nimport {\n IMemoryQuery,\n IMemoryRetriever,\n IMemoryRetrieverCapabilities,\n NON_SEMANTIC_CAPABILITIES,\n guardRetrieverCapabilities,\n limitRecords,\n orderingCompare,\n selectByQuery\n} from './retriever';\n\n/**\n * Returns records matching the query's scope / kind / tag / provenance-source / predicate filters,\n * ordered most-recently-updated first. The universal v1 retriever — an empty\n * query returns the whole vault in recency order.\n * @public\n */\nexport class RecencyRetriever implements IMemoryRetriever {\n private readonly _index: IMemoryIndex;\n\n private constructor(index: IMemoryIndex) {\n this._index = index;\n }\n\n /** {@inheritDoc IMemoryRetriever.capabilities} */\n public get capabilities(): IMemoryRetrieverCapabilities {\n return NON_SEMANTIC_CAPABILITIES;\n }\n\n /** Family-convention factory. */\n public static create(index: IMemoryIndex): Result<RecencyRetriever> {\n return succeed(new RecencyRetriever(index));\n }\n\n /** {@inheritDoc IMemoryRetriever.retrieve} */\n public retrieve(query: IMemoryQuery): Promise<Result<ReadonlyArray<IMemoryRecord<unknown>>>> {\n return Promise.resolve(\n guardRetrieverCapabilities(query, this.capabilities).onSuccess(() => {\n const ordered: IMemoryRecord<unknown>[] = selectByQuery(this._index.entries(), query).sort(\n orderingCompare(query.orderBy)\n );\n return succeed(limitRecords(ordered, query.limit, query.offset));\n })\n );\n }\n}\n"]}
1
+ {"version":3,"file":"recencyRetriever.js","sourceRoot":"","sources":["../../../src/packlets/retrieve/recencyRetriever.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAU,OAAO,EAAE,MAAM,eAAe,CAAC;AAGhD,OAAO,EAKL,yBAAyB,EACzB,0BAA0B,EAC1B,YAAY,EACb,MAAM,aAAa,CAAC;AAErB;;;;;GAKG;AACH,MAAM,OAAO,gBAAgB;IAI3B,YAAoB,MAA8B;QAChD,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC;QAC3B,IAAI,CAAC,SAAS,GAAG,MAAM,CAAC,QAAQ,CAAC;IACnC,CAAC;IAED,kDAAkD;IAClD,IAAW,YAAY;QACrB,OAAO,yBAAyB,CAAC;IACnC,CAAC;IAED,iCAAiC;IAC1B,MAAM,CAAC,MAAM,CAAC,MAA8B;QACjD,OAAO,OAAO,CAAC,IAAI,gBAAgB,CAAC,MAAM,CAAC,CAAC,CAAC;IAC/C,CAAC;IAED,8CAA8C;IACvC,QAAQ,CAAC,KAAmB;QACjC,OAAO,OAAO,CAAC,OAAO,CACpB,0BAA0B,CAAC,KAAK,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC,SAAS,CAAC,GAAG,EAAE;YAClE,OAAO,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;QACpE,CAAC,CAAC,CACH,CAAC;IACJ,CAAC;CACF","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result, succeed } from '@fgv/ts-utils';\nimport { IMemoryRecord, IMemoryRecordResolver } from '../types';\nimport { IMemoryIndex } from '../index';\nimport {\n IMemoryQuery,\n IMemoryRetriever,\n IMemoryRetrieverCapabilities,\n IRetrieverCreateParams,\n NON_SEMANTIC_CAPABILITIES,\n guardRetrieverCapabilities,\n resolveQuery\n} from './retriever';\n\n/**\n * Returns records matching the query's scope / kind / tag / provenance-source / predicate filters,\n * ordered most-recently-updated first. The universal v1 retriever — an empty\n * query returns the whole vault in recency order.\n * @public\n */\nexport class RecencyRetriever implements IMemoryRetriever {\n private readonly _index: IMemoryIndex;\n private readonly _resolver: IMemoryRecordResolver;\n\n private constructor(params: IRetrieverCreateParams) {\n this._index = params.index;\n this._resolver = params.resolver;\n }\n\n /** {@inheritDoc IMemoryRetriever.capabilities} */\n public get capabilities(): IMemoryRetrieverCapabilities {\n return NON_SEMANTIC_CAPABILITIES;\n }\n\n /** Family-convention factory. */\n public static create(params: IRetrieverCreateParams): Result<RecencyRetriever> {\n return succeed(new RecencyRetriever(params));\n }\n\n /** {@inheritDoc IMemoryRetriever.retrieve} */\n public retrieve(query: IMemoryQuery): Promise<Result<ReadonlyArray<IMemoryRecord<unknown>>>> {\n return Promise.resolve(\n guardRetrieverCapabilities(query, this.capabilities).onSuccess(() => {\n return resolveQuery(this._index.entries(), query, this._resolver);\n })\n );\n }\n}\n"]}
@@ -2,7 +2,7 @@
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 { fail, mapResults, succeed } from '@fgv/ts-utils';
6
6
  /**
7
7
  * The loud-degradation message a retriever returns when `query.semantic` is set
8
8
  * but no {@link IVectorIndex} is wired.
@@ -103,22 +103,21 @@ export function indexedRecordMatchesQuery(entry, query) {
103
103
  if (query.scope !== undefined && entry.scope !== query.scope) {
104
104
  return false;
105
105
  }
106
- if (query.kind !== undefined && entry.record.envelope.kind !== query.kind) {
106
+ if (query.kind !== undefined && entry.envelope.kind !== query.kind) {
107
107
  return false;
108
108
  }
109
- if (query.kinds !== undefined && !query.kinds.includes(entry.record.envelope.kind)) {
109
+ if (query.kinds !== undefined && !query.kinds.includes(entry.envelope.kind)) {
110
110
  return false;
111
111
  }
112
- if (query.tag !== undefined && !entry.record.envelope.tags.includes(query.tag)) {
112
+ if (query.tag !== undefined && !entry.envelope.tags.includes(query.tag)) {
113
113
  return false;
114
114
  }
115
- if (query.provenanceSource !== undefined &&
116
- entry.record.envelope.provenance.source !== query.provenanceSource) {
117
- return false;
118
- }
119
- if (query.filter !== undefined && !query.filter(entry.record)) {
115
+ if (query.provenanceSource !== undefined && entry.envelope.provenance.source !== query.provenanceSource) {
120
116
  return false;
121
117
  }
118
+ // `query.filter` is deliberately NOT applied here — it is a predicate over the
119
+ // whole record, and the index holds envelopes. It runs after materialization;
120
+ // see `resolveQuery`.
122
121
  return true;
123
122
  }
124
123
  /**
@@ -128,7 +127,89 @@ export function indexedRecordMatchesQuery(entry, query) {
128
127
  * @public
129
128
  */
130
129
  export function selectByQuery(entries, query) {
131
- return entries.filter((entry) => indexedRecordMatchesQuery(entry, query)).map((entry) => entry.record);
130
+ return entries.filter((entry) => indexedRecordMatchesQuery(entry, query));
131
+ }
132
+ /**
133
+ * The shared select → order → page → materialize pipeline every non-semantic
134
+ * retriever runs, and the one place the body-vs-envelope ordering decision lives.
135
+ *
136
+ * @public
137
+ * @remarks
138
+ * **Without `query.filter`, ordering and paging happen over envelopes and only
139
+ * the page is materialized** — so `limit` genuinely bounds the number of records
140
+ * read, not merely the number returned. That is what makes `limit` a legitimate
141
+ * narrowing axis rather than a loophole, and it is conditional on the ordering
142
+ * key being an envelope field: both shipped comparators (`recencyCompare`,
143
+ * `rankCompare`) read `updated` / `seq` / `rank` and qualify. **An ordering that
144
+ * keyed on a body field could not use this path** and would have to sort after
145
+ * materialization, at which point `limit` bounds the result and not the read.
146
+ *
147
+ * **With `query.filter`, every envelope-survivor must be materialized first**,
148
+ * because the predicate takes a whole record. Semantics are preserved exactly;
149
+ * the cost is not. Pair `filter` with an envelope axis when the read cost
150
+ * matters.
151
+ */
152
+ export function resolveQuery(entries, query, resolver) {
153
+ return materializePage(selectByQuery(entries, query), query, resolver, (candidates) => [...candidates].sort(orderingCompare(query.orderBy)));
154
+ }
155
+ /**
156
+ * Materialize a selected, ordered page — **applying `query.filter` and paging in
157
+ * the right order**, which is the whole reason this is shared rather than
158
+ * open-coded per retriever.
159
+ *
160
+ * @remarks
161
+ * Two paths, and the choice is forced by where the predicate can run:
162
+ *
163
+ * - **No `filter`** — order and page over *envelopes*, then read only the page.
164
+ * `limit` bounds the READ, not just the result.
165
+ * - **With `filter`** — the predicate takes a whole record, so every
166
+ * envelope-survivor must be read first, then filtered, then paged. Paging
167
+ * before filtering would return fewer than `limit` rows for no reason a caller
168
+ * could see.
169
+ *
170
+ * **Every retriever must route through this.** `indexedRecordMatchesQuery`
171
+ * structurally *cannot* apply `filter` — it is handed an envelope — so a
172
+ * retriever that pre-filters with it and then materializes on its own silently
173
+ * ignores the predicate. That regression shipped once, in the stream that moved
174
+ * `filter` out of the pre-filter; this function exists so it cannot recur.
175
+ *
176
+ * @param selected - Entries surviving the envelope pre-filter.
177
+ * @param query - The query whose `filter` / `orderBy` / `limit` / `offset` apply.
178
+ * @param resolver - Body resolver.
179
+ * @param order - Applied to whichever collection is paged; identity is legal for
180
+ * a retriever whose ordering is intrinsic (semantic score, traversal order).
181
+ * @public
182
+ */
183
+ export function materializePage(selected, query, resolver, order) {
184
+ const ordered = order === undefined ? selected : order(selected);
185
+ if (query.filter === undefined) {
186
+ return materializeEntries(limitEntries(ordered, query.limit, query.offset), resolver);
187
+ }
188
+ const predicate = query.filter;
189
+ return materializeEntries(ordered, resolver).onSuccess((records) => succeed(limitRecords(records.filter(predicate), query.limit, query.offset)));
190
+ }
191
+ /**
192
+ * Materialize entries through the resolver, dropping any that have vanished
193
+ * since selection — a concurrent delete between selecting an envelope and
194
+ * reading its body is a legitimate race and yields a shorter list, not an error.
195
+ * A read that FAILS is a real fault and propagates.
196
+ * @public
197
+ */
198
+ export function materializeEntries(entries, resolver) {
199
+ return mapResults(entries.map((e) => resolver.resolveRecord(e.scope, e.envelope.id))).onSuccess((records) => succeed(records.filter((r) => r !== undefined)));
200
+ }
201
+ /**
202
+ * The {@link limitRecords} window, applied to entries — used on the
203
+ * no-body-filter path so paging happens before anything is read.
204
+ * @public
205
+ */
206
+ export function limitEntries(entries, limit, offset) {
207
+ const skip = offset !== undefined && offset > 0 ? offset : 0;
208
+ const windowed = skip > 0 ? entries.slice(skip) : entries;
209
+ if (limit === undefined) {
210
+ return windowed;
211
+ }
212
+ return limit > 0 ? windowed.slice(0, limit) : [];
132
213
  }
133
214
  /**
134
215
  * Apply the `{ offset, limit }` page window to an ordered record set. Applied
@@ -1 +1 @@
1
- {"version":3,"file":"retriever.js","sourceRoot":"","sources":["../../../src/packlets/retrieve/retriever.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result, fail, succeed } from '@fgv/ts-utils';\nimport { IEdgeTarget, IMemoryRecord, Kind, MemoryScopeKey, ProvenanceSource, Tag } from '../types';\nimport { IIndexedMemoryRecord } from '../index';\n\n/**\n * The capabilities a retriever exposes. A consumer probes these before\n * dispatching a query so it never silently gets an empty result for a\n * capability the retriever does not implement.\n * @public\n */\nexport interface IMemoryRetrieverCapabilities {\n /** Semantic / vector recall is operational (an {@link IVectorIndex} is wired). */\n readonly supportsSemanticRecall: boolean;\n /** Temporal \"as-of\" queries are operational (a temporal index is wired). */\n readonly supportsTemporalQuery: boolean;\n /** Link traversal is supported (an in-memory backlink index is present). */\n readonly supportsLinkTraversal: boolean;\n}\n\n/**\n * A retrieval query. Every field is optional; an empty query is the \"recency\n * over everything\" request. `semantic` and `asOf` are present from day one (the\n * no-resignature guarantee): a backend that adds semantic or temporal recall\n * sets the matching capability flag, with no interface change.\n * @public\n */\nexport interface IMemoryQuery {\n /** Restrict to records in this scope. */\n readonly scope?: MemoryScopeKey;\n /** Restrict to records carrying this tag (exact match). */\n readonly tag?: Tag;\n /**\n * Restrict to records whose {@link IProvenance.source | provenance.source} is\n * exactly this value — the \"show me everything this source produced\" axis, for\n * review, attribution, and retraction after a bad ingest.\n *\n * @remarks\n * Exact match on `source` only, deliberately: not a subset match over the whole\n * {@link IProvenance} block, not a presence check, and not a match on `by` /\n * `model` / the consumer-owned extension keys. `source` is the only field of\n * that block the library assigns meaning to, and every record carries one\n * (`envelope.provenance` is required), so this axis is total — it partitions\n * the vault rather than filtering out records that merely omit a field.\n *\n * Applied in the shared pre-filter alongside `scope` / `kind` / `tag`, so every\n * retriever honours it and combining axes composes as AND.\n */\n readonly provenanceSource?: ProvenanceSource;\n /**\n * Restrict to records of this kind — the single-kind shorthand for\n * {@link IMemoryQuery.kinds | kinds}. When both are set they compose as AND\n * (the record's kind must satisfy both), so `kind` must itself be a member of\n * `kinds` for anything to match.\n */\n readonly kind?: Kind;\n /**\n * Restrict to records in ANY of these kinds — the general (multi-kind) form of\n * {@link IMemoryQuery.kind | kind}. Absent → no kind-set constraint (today's\n * behavior). An explicit empty array `[]` matches NOTHING (mirroring the\n * non-positive-`limit` \"explicit empty\" convention), never \"match all\".\n */\n readonly kinds?: ReadonlyArray<Kind>;\n /** Restrict to records linked FROM this scope-qualified seed (outbound). */\n readonly linkedFrom?: IEdgeTarget;\n /** Restrict to records linked TO this scope-qualified seed (inbound / backlinks). */\n readonly linkedTo?: IEdgeTarget;\n /** BFS hop count for link traversal. Default: 1. */\n readonly hops?: number;\n /**\n * Text query for semantic / vector recall. If set and the retriever's\n * `supportsSemanticRecall` is `false`, the retriever returns a loud\n * `Result.fail` ({@link SEMANTIC_UNWIRED_MESSAGE}) — never a silent empty.\n */\n readonly semantic?: string;\n /** Top-K for semantic recall. Default: 10. */\n readonly topK?: number;\n /**\n * As-of epoch ms for temporal \"valid at\" queries. If set and the retriever's\n * `supportsTemporalQuery` is `false`, the retriever returns a loud\n * `Result.fail` — never a silent empty.\n */\n readonly asOf?: number;\n /**\n * Ordering for the result set. `'recency'` (the default when absent — today's\n * exact behavior) orders most-recently-updated first; `'rank'` orders by the\n * store-computed {@link IMemoryEnvelope.rank} descending (records with an absent\n * `rank` last), with recency as the tiebreak. Combined with `{ limit, offset }`\n * this yields a bounded top-M rank-ordered page with no full-vault scan.\n *\n * @remarks\n * `orderBy` governs the ordered non-semantic retrievers (recency / tag /\n * structured-filter / link-traversal) and the {@link HybridRetriever}'s\n * post-merge ordering. The {@link SemanticRetriever} is the sole exception: it\n * preserves its native vector-similarity order regardless of `orderBy` —\n * re-sorting semantic hits by `rank` would discard the similarity ranking that\n * is the whole point of that path; a consumer that wants rank ordering uses a\n * non-semantic query.\n */\n readonly orderBy?: 'recency' | 'rank';\n /** Maximum records to return. Applied after all other filters. */\n readonly limit?: number;\n /**\n * Records to skip after ordering, before `limit` — so `{ offset, limit }` is a\n * stable page window over the ordered result set. Default 0. A non-positive or\n * absent offset is today's behavior (no skip); an offset past the end yields an\n * empty page, never a throw.\n */\n readonly offset?: number;\n /** Arbitrary predicate applied after the scope / kind / tag / provenance-source pre-filter. */\n readonly filter?: (record: IMemoryRecord<unknown>) => boolean;\n}\n\n/**\n * The retrieval contract. A retriever exposes its {@link\n * IMemoryRetrieverCapabilities | capabilities} and answers\n * {@link IMemoryRetriever.retrieve | queries}, degrading loudly (never silently\n * empty) when a requested capability is not wired.\n * @public\n */\nexport interface IMemoryRetriever {\n /** The capabilities this retriever exposes. Probe before dispatch. */\n readonly capabilities: IMemoryRetrieverCapabilities;\n /**\n * Retrieve records matching `query`. Returns a `Result.fail` with a\n * diagnostic message when the query requests a capability this retriever does\n * not support (never an empty success).\n */\n retrieve(query: IMemoryQuery): Promise<Result<ReadonlyArray<IMemoryRecord<unknown>>>>;\n}\n\n/**\n * The loud-degradation message a retriever returns when `query.semantic` is set\n * but no {@link IVectorIndex} is wired.\n * @public\n */\nexport const SEMANTIC_UNWIRED_MESSAGE: string = 'semantic recall requires a vector index; none configured';\n\n/**\n * The loud-degradation message a retriever returns when a link-traversal axis\n * (`linkedFrom` / `linkedTo` / `hops`) is requested but no backlink index is\n * wired.\n * @public\n */\nexport const LINK_TRAVERSAL_UNWIRED_MESSAGE: string =\n 'link traversal requires a backlink index; none configured';\n\n/**\n * The capabilities every non-semantic, non-temporal, non-link v1 retriever\n * exposes (all three flags `false`).\n * @public\n */\nexport const NON_SEMANTIC_CAPABILITIES: IMemoryRetrieverCapabilities = {\n supportsSemanticRecall: false,\n supportsTemporalQuery: false,\n supportsLinkTraversal: false\n};\n\n/**\n * Build the loud-degradation message a retriever returns when `query.asOf` is\n * set but no temporal index is wired.\n * @public\n */\nexport function temporalUnwiredMessage(kind?: Kind): string {\n return `temporal query requires temporal index; none configured${\n kind !== undefined ? ` for kind ${kind}` : ''\n }`;\n}\n\n/**\n * Enforce the loud-degradation contract for the `semantic` and `asOf` axes\n * against a retriever's `capabilities`: a requested capability the retriever\n * does not support fails loudly rather than returning a silent empty result.\n * @public\n */\nexport function guardRetrieverCapabilities(\n query: IMemoryQuery,\n capabilities: IMemoryRetrieverCapabilities\n): Result<true> {\n if (query.semantic !== undefined && !capabilities.supportsSemanticRecall) {\n return fail(SEMANTIC_UNWIRED_MESSAGE);\n }\n if (query.asOf !== undefined && !capabilities.supportsTemporalQuery) {\n return fail(temporalUnwiredMessage(query.kind));\n }\n const requestsLinkTraversal: boolean =\n query.linkedFrom !== undefined || query.linkedTo !== undefined || query.hops !== undefined;\n if (requestsLinkTraversal && !capabilities.supportsLinkTraversal) {\n return fail(LINK_TRAVERSAL_UNWIRED_MESSAGE);\n }\n return succeed(true);\n}\n\n/**\n * Recency comparator: most-recently-updated first, with a `seq` tiebreak so\n * equal-`updated` records sort deterministically. Mirrors the B1 index ordering.\n * @public\n */\nexport function recencyCompare(a: IMemoryRecord<unknown>, b: IMemoryRecord<unknown>): number {\n const byUpdated: number = b.envelope.updated - a.envelope.updated;\n return byUpdated !== 0 ? byUpdated : b.envelope.seq - a.envelope.seq;\n}\n\n/**\n * Rank comparator: store-computed {@link IMemoryEnvelope.rank} descending, with\n * {@link recencyCompare} as the tiebreak. Records with an absent `rank` sort LAST\n * (after every ranked record), then by recency among themselves. Mirrors the\n * index's rank-view ordering.\n * @public\n */\nexport function rankCompare(a: IMemoryRecord<unknown>, b: IMemoryRecord<unknown>): number {\n const ra: number | undefined = a.envelope.rank;\n const rb: number | undefined = b.envelope.rank;\n if (ra === undefined && rb !== undefined) {\n return 1;\n }\n if (rb === undefined && ra !== undefined) {\n return -1;\n }\n if (ra !== undefined && rb !== undefined && ra !== rb) {\n return rb - ra;\n }\n return recencyCompare(a, b);\n}\n\n/**\n * Select the record comparator for a query's {@link IMemoryQuery.orderBy | orderBy}\n * axis: {@link rankCompare} for `'rank'`, {@link recencyCompare} otherwise (the\n * default, byte-identical to the pre-`orderBy` behavior).\n * @public\n */\nexport function orderingCompare(\n orderBy?: IMemoryQuery['orderBy']\n): (a: IMemoryRecord<unknown>, b: IMemoryRecord<unknown>) => number {\n return orderBy === 'rank' ? rankCompare : recencyCompare;\n}\n\n/**\n * Whether an indexed entry satisfies a query's scope / kind / tag /\n * provenance-source / predicate pre-filter (the axes shared by every v1\n * retriever). The `semantic` / `asOf` / link axes are NOT applied here — those\n * are each retriever's own concern.\n * @public\n */\nexport function indexedRecordMatchesQuery(entry: IIndexedMemoryRecord, query: IMemoryQuery): boolean {\n if (query.scope !== undefined && entry.scope !== query.scope) {\n return false;\n }\n if (query.kind !== undefined && entry.record.envelope.kind !== query.kind) {\n return false;\n }\n if (query.kinds !== undefined && !query.kinds.includes(entry.record.envelope.kind)) {\n return false;\n }\n if (query.tag !== undefined && !entry.record.envelope.tags.includes(query.tag)) {\n return false;\n }\n if (\n query.provenanceSource !== undefined &&\n entry.record.envelope.provenance.source !== query.provenanceSource\n ) {\n return false;\n }\n if (query.filter !== undefined && !query.filter(entry.record)) {\n return false;\n }\n return true;\n}\n\n/**\n * Apply the shared scope / kind / tag / provenance-source / predicate pre-filter\n * to a set of indexed entries, returning the surviving records (unordered,\n * unlimited).\n * @public\n */\nexport function selectByQuery(\n entries: ReadonlyArray<IIndexedMemoryRecord>,\n query: IMemoryQuery\n): IMemoryRecord<unknown>[] {\n return entries.filter((entry) => indexedRecordMatchesQuery(entry, query)).map((entry) => entry.record);\n}\n\n/**\n * Apply the `{ offset, limit }` page window to an ordered record set. Applied\n * last, after ordering, so it always takes a stable window of the ordered\n * result. `offset` is applied first (records to skip), then `limit` (top-N of\n * the remainder).\n *\n * @remarks\n * Both bounds are public query input and are guarded against non-positive\n * values slipping into `slice`:\n * - `offset` absent or non-positive → no skip (today's behavior). An offset past\n * the end yields an empty page rather than a throw.\n * - `limit` absent → no truncation; a non-positive `limit` means \"no records\"\n * and returns an empty array.\n * @public\n */\nexport function limitRecords(\n records: ReadonlyArray<IMemoryRecord<unknown>>,\n limit?: number,\n offset?: number\n): ReadonlyArray<IMemoryRecord<unknown>> {\n const skip: number = offset !== undefined && offset > 0 ? offset : 0;\n const windowed: ReadonlyArray<IMemoryRecord<unknown>> = skip > 0 ? records.slice(skip) : records;\n if (limit === undefined) {\n return windowed;\n }\n if (limit <= 0) {\n return [];\n }\n return windowed.length > limit ? windowed.slice(0, limit) : windowed;\n}\n"]}
1
+ {"version":3,"file":"retriever.js","sourceRoot":"","sources":["../../../src/packlets/retrieve/retriever.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result, fail, mapResults, succeed } from '@fgv/ts-utils';\nimport {\n IEdgeTarget,\n IEnvelopeCarrier,\n IMemoryRecord,\n IMemoryRecordResolver,\n Kind,\n MemoryScopeKey,\n ProvenanceSource,\n Tag\n} from '../types';\nimport { IIndexedMemoryEntry, IMemoryIndex } from '../index';\n\n/**\n * What every index-backed retriever needs: the index it selects over, and the\n * resolver it materializes survivors through.\n *\n * @remarks\n * The resolver is the whole of the partial-read migration for a caller that\n * constructs retrievers directly — the index projection itself is invisible to\n * anyone who only consumes `retrieve()`. `FileTreeMemoryStore` implements\n * {@link IMemoryRecordResolver}, so wiring is `{ index, resolver: store }`.\n * @public\n */\nexport interface IRetrieverCreateParams {\n /** The derived index to select over. */\n readonly index: IMemoryIndex;\n /** Materializes the selected entries' bodies. */\n readonly resolver: IMemoryRecordResolver;\n}\n\n/**\n * The capabilities a retriever exposes. A consumer probes these before\n * dispatching a query so it never silently gets an empty result for a\n * capability the retriever does not implement.\n * @public\n */\nexport interface IMemoryRetrieverCapabilities {\n /** Semantic / vector recall is operational (an {@link IVectorIndex} is wired). */\n readonly supportsSemanticRecall: boolean;\n /** Temporal \"as-of\" queries are operational (a temporal index is wired). */\n readonly supportsTemporalQuery: boolean;\n /** Link traversal is supported (an in-memory backlink index is present). */\n readonly supportsLinkTraversal: boolean;\n}\n\n/**\n * A retrieval query. Every field is optional; an empty query is the \"recency\n * over everything\" request. `semantic` and `asOf` are present from day one (the\n * no-resignature guarantee): a backend that adds semantic or temporal recall\n * sets the matching capability flag, with no interface change.\n * @public\n */\nexport interface IMemoryQuery {\n /** Restrict to records in this scope. */\n readonly scope?: MemoryScopeKey;\n /** Restrict to records carrying this tag (exact match). */\n readonly tag?: Tag;\n /**\n * Restrict to records whose {@link IProvenance.source | provenance.source} is\n * exactly this value — the \"show me everything this source produced\" axis, for\n * review, attribution, and retraction after a bad ingest.\n *\n * @remarks\n * Exact match on `source` only, deliberately: not a subset match over the whole\n * {@link IProvenance} block, not a presence check, and not a match on `by` /\n * `model` / the consumer-owned extension keys. `source` is the only field of\n * that block the library assigns meaning to, and every record carries one\n * (`envelope.provenance` is required), so this axis is total — it partitions\n * the vault rather than filtering out records that merely omit a field.\n *\n * Applied in the shared pre-filter alongside `scope` / `kind` / `tag`, so every\n * retriever honours it and combining axes composes as AND.\n */\n readonly provenanceSource?: ProvenanceSource;\n /**\n * Restrict to records of this kind — the single-kind shorthand for\n * {@link IMemoryQuery.kinds | kinds}. When both are set they compose as AND\n * (the record's kind must satisfy both), so `kind` must itself be a member of\n * `kinds` for anything to match.\n */\n readonly kind?: Kind;\n /**\n * Restrict to records in ANY of these kinds — the general (multi-kind) form of\n * {@link IMemoryQuery.kind | kind}. Absent → no kind-set constraint (today's\n * behavior). An explicit empty array `[]` matches NOTHING (mirroring the\n * non-positive-`limit` \"explicit empty\" convention), never \"match all\".\n */\n readonly kinds?: ReadonlyArray<Kind>;\n /** Restrict to records linked FROM this scope-qualified seed (outbound). */\n readonly linkedFrom?: IEdgeTarget;\n /** Restrict to records linked TO this scope-qualified seed (inbound / backlinks). */\n readonly linkedTo?: IEdgeTarget;\n /** BFS hop count for link traversal. Default: 1. */\n readonly hops?: number;\n /**\n * Text query for semantic / vector recall. If set and the retriever's\n * `supportsSemanticRecall` is `false`, the retriever returns a loud\n * `Result.fail` ({@link SEMANTIC_UNWIRED_MESSAGE}) — never a silent empty.\n */\n readonly semantic?: string;\n /** Top-K for semantic recall. Default: 10. */\n readonly topK?: number;\n /**\n * As-of epoch ms for temporal \"valid at\" queries. If set and the retriever's\n * `supportsTemporalQuery` is `false`, the retriever returns a loud\n * `Result.fail` — never a silent empty.\n */\n readonly asOf?: number;\n /**\n * Ordering for the result set. `'recency'` (the default when absent — today's\n * exact behavior) orders most-recently-updated first; `'rank'` orders by the\n * store-computed {@link IMemoryEnvelope.rank} descending (records with an absent\n * `rank` last), with recency as the tiebreak. Combined with `{ limit, offset }`\n * this yields a bounded top-M rank-ordered page with no full-vault scan.\n *\n * @remarks\n * `orderBy` governs the ordered non-semantic retrievers (recency / tag /\n * structured-filter / link-traversal) and the {@link HybridRetriever}'s\n * post-merge ordering. The {@link SemanticRetriever} is the sole exception: it\n * preserves its native vector-similarity order regardless of `orderBy` —\n * re-sorting semantic hits by `rank` would discard the similarity ranking that\n * is the whole point of that path; a consumer that wants rank ordering uses a\n * non-semantic query.\n */\n readonly orderBy?: 'recency' | 'rank';\n /** Maximum records to return. Applied after all other filters. */\n readonly limit?: number;\n /**\n * Records to skip after ordering, before `limit` — so `{ offset, limit }` is a\n * stable page window over the ordered result set. Default 0. A non-positive or\n * absent offset is today's behavior (no skip); an offset past the end yields an\n * empty page, never a throw.\n */\n readonly offset?: number;\n /** Arbitrary predicate applied after the scope / kind / tag / provenance-source pre-filter. */\n readonly filter?: (record: IMemoryRecord<unknown>) => boolean;\n}\n\n/**\n * The retrieval contract. A retriever exposes its {@link\n * IMemoryRetrieverCapabilities | capabilities} and answers\n * {@link IMemoryRetriever.retrieve | queries}, degrading loudly (never silently\n * empty) when a requested capability is not wired.\n * @public\n */\nexport interface IMemoryRetriever {\n /** The capabilities this retriever exposes. Probe before dispatch. */\n readonly capabilities: IMemoryRetrieverCapabilities;\n /**\n * Retrieve records matching `query`. Returns a `Result.fail` with a\n * diagnostic message when the query requests a capability this retriever does\n * not support (never an empty success).\n */\n retrieve(query: IMemoryQuery): Promise<Result<ReadonlyArray<IMemoryRecord<unknown>>>>;\n}\n\n/**\n * The loud-degradation message a retriever returns when `query.semantic` is set\n * but no {@link IVectorIndex} is wired.\n * @public\n */\nexport const SEMANTIC_UNWIRED_MESSAGE: string = 'semantic recall requires a vector index; none configured';\n\n/**\n * The loud-degradation message a retriever returns when a link-traversal axis\n * (`linkedFrom` / `linkedTo` / `hops`) is requested but no backlink index is\n * wired.\n * @public\n */\nexport const LINK_TRAVERSAL_UNWIRED_MESSAGE: string =\n 'link traversal requires a backlink index; none configured';\n\n/**\n * The capabilities every non-semantic, non-temporal, non-link v1 retriever\n * exposes (all three flags `false`).\n * @public\n */\nexport const NON_SEMANTIC_CAPABILITIES: IMemoryRetrieverCapabilities = {\n supportsSemanticRecall: false,\n supportsTemporalQuery: false,\n supportsLinkTraversal: false\n};\n\n/**\n * Build the loud-degradation message a retriever returns when `query.asOf` is\n * set but no temporal index is wired.\n * @public\n */\nexport function temporalUnwiredMessage(kind?: Kind): string {\n return `temporal query requires temporal index; none configured${\n kind !== undefined ? ` for kind ${kind}` : ''\n }`;\n}\n\n/**\n * Enforce the loud-degradation contract for the `semantic` and `asOf` axes\n * against a retriever's `capabilities`: a requested capability the retriever\n * does not support fails loudly rather than returning a silent empty result.\n * @public\n */\nexport function guardRetrieverCapabilities(\n query: IMemoryQuery,\n capabilities: IMemoryRetrieverCapabilities\n): Result<true> {\n if (query.semantic !== undefined && !capabilities.supportsSemanticRecall) {\n return fail(SEMANTIC_UNWIRED_MESSAGE);\n }\n if (query.asOf !== undefined && !capabilities.supportsTemporalQuery) {\n return fail(temporalUnwiredMessage(query.kind));\n }\n const requestsLinkTraversal: boolean =\n query.linkedFrom !== undefined || query.linkedTo !== undefined || query.hops !== undefined;\n if (requestsLinkTraversal && !capabilities.supportsLinkTraversal) {\n return fail(LINK_TRAVERSAL_UNWIRED_MESSAGE);\n }\n return succeed(true);\n}\n\n/**\n * Recency comparator: most-recently-updated first, with a `seq` tiebreak so\n * equal-`updated` records sort deterministically. Mirrors the B1 index ordering.\n * @public\n */\nexport function recencyCompare(a: IEnvelopeCarrier, b: IEnvelopeCarrier): number {\n const byUpdated: number = b.envelope.updated - a.envelope.updated;\n return byUpdated !== 0 ? byUpdated : b.envelope.seq - a.envelope.seq;\n}\n\n/**\n * Rank comparator: store-computed {@link IMemoryEnvelope.rank} descending, with\n * {@link recencyCompare} as the tiebreak. Records with an absent `rank` sort LAST\n * (after every ranked record), then by recency among themselves. Mirrors the\n * index's rank-view ordering.\n * @public\n */\nexport function rankCompare(a: IEnvelopeCarrier, b: IEnvelopeCarrier): number {\n const ra: number | undefined = a.envelope.rank;\n const rb: number | undefined = b.envelope.rank;\n if (ra === undefined && rb !== undefined) {\n return 1;\n }\n if (rb === undefined && ra !== undefined) {\n return -1;\n }\n if (ra !== undefined && rb !== undefined && ra !== rb) {\n return rb - ra;\n }\n return recencyCompare(a, b);\n}\n\n/**\n * Select the record comparator for a query's {@link IMemoryQuery.orderBy | orderBy}\n * axis: {@link rankCompare} for `'rank'`, {@link recencyCompare} otherwise (the\n * default, byte-identical to the pre-`orderBy` behavior).\n * @public\n */\nexport function orderingCompare(\n orderBy?: IMemoryQuery['orderBy']\n): (a: IEnvelopeCarrier, b: IEnvelopeCarrier) => number {\n return orderBy === 'rank' ? rankCompare : recencyCompare;\n}\n\n/**\n * Whether an indexed entry satisfies a query's scope / kind / tag /\n * provenance-source / predicate pre-filter (the axes shared by every v1\n * retriever). The `semantic` / `asOf` / link axes are NOT applied here — those\n * are each retriever's own concern.\n * @public\n */\nexport function indexedRecordMatchesQuery(entry: IIndexedMemoryEntry, query: IMemoryQuery): boolean {\n if (query.scope !== undefined && entry.scope !== query.scope) {\n return false;\n }\n if (query.kind !== undefined && entry.envelope.kind !== query.kind) {\n return false;\n }\n if (query.kinds !== undefined && !query.kinds.includes(entry.envelope.kind)) {\n return false;\n }\n if (query.tag !== undefined && !entry.envelope.tags.includes(query.tag)) {\n return false;\n }\n if (query.provenanceSource !== undefined && entry.envelope.provenance.source !== query.provenanceSource) {\n return false;\n }\n // `query.filter` is deliberately NOT applied here — it is a predicate over the\n // whole record, and the index holds envelopes. It runs after materialization;\n // see `resolveQuery`.\n return true;\n}\n\n/**\n * Apply the shared scope / kind / tag / provenance-source / predicate pre-filter\n * to a set of indexed entries, returning the surviving records (unordered,\n * unlimited).\n * @public\n */\nexport function selectByQuery(\n entries: ReadonlyArray<IIndexedMemoryEntry>,\n query: IMemoryQuery\n): IIndexedMemoryEntry[] {\n return entries.filter((entry) => indexedRecordMatchesQuery(entry, query));\n}\n\n/**\n * The shared select → order → page → materialize pipeline every non-semantic\n * retriever runs, and the one place the body-vs-envelope ordering decision lives.\n *\n * @public\n * @remarks\n * **Without `query.filter`, ordering and paging happen over envelopes and only\n * the page is materialized** — so `limit` genuinely bounds the number of records\n * read, not merely the number returned. That is what makes `limit` a legitimate\n * narrowing axis rather than a loophole, and it is conditional on the ordering\n * key being an envelope field: both shipped comparators (`recencyCompare`,\n * `rankCompare`) read `updated` / `seq` / `rank` and qualify. **An ordering that\n * keyed on a body field could not use this path** and would have to sort after\n * materialization, at which point `limit` bounds the result and not the read.\n *\n * **With `query.filter`, every envelope-survivor must be materialized first**,\n * because the predicate takes a whole record. Semantics are preserved exactly;\n * the cost is not. Pair `filter` with an envelope axis when the read cost\n * matters.\n */\nexport function resolveQuery(\n entries: ReadonlyArray<IIndexedMemoryEntry>,\n query: IMemoryQuery,\n resolver: IMemoryRecordResolver\n): Result<ReadonlyArray<IMemoryRecord<unknown>>> {\n return materializePage(selectByQuery(entries, query), query, resolver, (candidates) =>\n [...candidates].sort(orderingCompare(query.orderBy))\n );\n}\n\n/**\n * Materialize a selected, ordered page — **applying `query.filter` and paging in\n * the right order**, which is the whole reason this is shared rather than\n * open-coded per retriever.\n *\n * @remarks\n * Two paths, and the choice is forced by where the predicate can run:\n *\n * - **No `filter`** — order and page over *envelopes*, then read only the page.\n * `limit` bounds the READ, not just the result.\n * - **With `filter`** — the predicate takes a whole record, so every\n * envelope-survivor must be read first, then filtered, then paged. Paging\n * before filtering would return fewer than `limit` rows for no reason a caller\n * could see.\n *\n * **Every retriever must route through this.** `indexedRecordMatchesQuery`\n * structurally *cannot* apply `filter` — it is handed an envelope — so a\n * retriever that pre-filters with it and then materializes on its own silently\n * ignores the predicate. That regression shipped once, in the stream that moved\n * `filter` out of the pre-filter; this function exists so it cannot recur.\n *\n * @param selected - Entries surviving the envelope pre-filter.\n * @param query - The query whose `filter` / `orderBy` / `limit` / `offset` apply.\n * @param resolver - Body resolver.\n * @param order - Applied to whichever collection is paged; identity is legal for\n * a retriever whose ordering is intrinsic (semantic score, traversal order).\n * @public\n */\nexport function materializePage<T extends IIndexedMemoryEntry>(\n selected: ReadonlyArray<T>,\n query: IMemoryQuery,\n resolver: IMemoryRecordResolver,\n order?: (candidates: ReadonlyArray<T>) => ReadonlyArray<T>\n): Result<ReadonlyArray<IMemoryRecord<unknown>>> {\n const ordered: ReadonlyArray<T> = order === undefined ? selected : order(selected);\n if (query.filter === undefined) {\n return materializeEntries(limitEntries(ordered, query.limit, query.offset), resolver);\n }\n const predicate: (record: IMemoryRecord<unknown>) => boolean = query.filter;\n return materializeEntries(ordered, resolver).onSuccess((records) =>\n succeed(limitRecords(records.filter(predicate), query.limit, query.offset))\n );\n}\n\n/**\n * Materialize entries through the resolver, dropping any that have vanished\n * since selection — a concurrent delete between selecting an envelope and\n * reading its body is a legitimate race and yields a shorter list, not an error.\n * A read that FAILS is a real fault and propagates.\n * @public\n */\nexport function materializeEntries(\n entries: ReadonlyArray<IIndexedMemoryEntry>,\n resolver: IMemoryRecordResolver\n): Result<ReadonlyArray<IMemoryRecord<unknown>>> {\n return mapResults(entries.map((e) => resolver.resolveRecord(e.scope, e.envelope.id))).onSuccess((records) =>\n succeed(records.filter((r): r is IMemoryRecord<unknown> => r !== undefined))\n );\n}\n\n/**\n * The {@link limitRecords} window, applied to entries — used on the\n * no-body-filter path so paging happens before anything is read.\n * @public\n */\nexport function limitEntries(\n entries: ReadonlyArray<IIndexedMemoryEntry>,\n limit?: number,\n offset?: number\n): ReadonlyArray<IIndexedMemoryEntry> {\n const skip: number = offset !== undefined && offset > 0 ? offset : 0;\n const windowed: ReadonlyArray<IIndexedMemoryEntry> = skip > 0 ? entries.slice(skip) : entries;\n if (limit === undefined) {\n return windowed;\n }\n return limit > 0 ? windowed.slice(0, limit) : [];\n}\n\n/**\n * Apply the `{ offset, limit }` page window to an ordered record set. Applied\n * last, after ordering, so it always takes a stable window of the ordered\n * result. `offset` is applied first (records to skip), then `limit` (top-N of\n * the remainder).\n *\n * @remarks\n * Both bounds are public query input and are guarded against non-positive\n * values slipping into `slice`:\n * - `offset` absent or non-positive → no skip (today's behavior). An offset past\n * the end yields an empty page rather than a throw.\n * - `limit` absent → no truncation; a non-positive `limit` means \"no records\"\n * and returns an empty array.\n * @public\n */\nexport function limitRecords(\n records: ReadonlyArray<IMemoryRecord<unknown>>,\n limit?: number,\n offset?: number\n): ReadonlyArray<IMemoryRecord<unknown>> {\n const skip: number = offset !== undefined && offset > 0 ? offset : 0;\n const windowed: ReadonlyArray<IMemoryRecord<unknown>> = skip > 0 ? records.slice(skip) : records;\n if (limit === undefined) {\n return windowed;\n }\n if (limit <= 0) {\n return [];\n }\n return windowed.length > limit ? windowed.slice(0, limit) : windowed;\n}\n"]}