@retinue/agentkit 0.2.0 → 0.3.1

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 (209) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/audio/openai.d.ts +36 -0
  3. package/dist/adapters/audio/openai.js +136 -0
  4. package/dist/adapters/bullmq/lock.d.ts +26 -2
  5. package/dist/adapters/memory/connections.d.ts +10 -0
  6. package/dist/adapters/memory/connections.js +87 -0
  7. package/dist/adapters/memory/graph.d.ts +17 -0
  8. package/dist/adapters/memory/graph.js +267 -0
  9. package/dist/adapters/memory/index.d.ts +3 -0
  10. package/dist/adapters/memory/index.js +3 -0
  11. package/dist/adapters/memory/rate-limit.d.ts +14 -0
  12. package/dist/adapters/memory/rate-limit.js +28 -0
  13. package/dist/adapters/memory/runtime.js +0 -0
  14. package/dist/adapters/postgres/connections.d.ts +15 -0
  15. package/dist/adapters/postgres/connections.js +134 -0
  16. package/dist/adapters/postgres/graph.d.ts +24 -0
  17. package/dist/adapters/postgres/graph.js +325 -0
  18. package/dist/adapters/postgres/index.d.ts +2 -0
  19. package/dist/adapters/postgres/index.js +2 -0
  20. package/dist/adapters/postgres/migrations.js +198 -0
  21. package/dist/adapters/postgres/run-store.js +26 -2
  22. package/dist/adapters/postgres/schema.d.ts +61 -2
  23. package/dist/adapters/postgres/schema.js +95 -10
  24. package/dist/adapters/redis/index.d.ts +1 -0
  25. package/dist/adapters/redis/index.js +1 -0
  26. package/dist/adapters/redis/rate-limit.d.ts +46 -0
  27. package/dist/adapters/redis/rate-limit.js +55 -0
  28. package/dist/adapters/supabase/index.d.ts +15 -0
  29. package/dist/adapters/supabase/index.js +15 -0
  30. package/dist/adapters/supabase/rls.js +33 -0
  31. package/dist/agents/agent.d.ts +78 -1
  32. package/dist/agents/agent.js +118 -5
  33. package/dist/agents/define.js +24 -4
  34. package/dist/agents/engine.d.ts +20 -0
  35. package/dist/agents/engine.js +177 -6
  36. package/dist/audio/index.d.ts +151 -0
  37. package/dist/audio/index.js +183 -0
  38. package/dist/connections/cipher.d.ts +103 -0
  39. package/dist/connections/cipher.js +141 -0
  40. package/dist/connections/index.d.ts +156 -0
  41. package/dist/connections/index.js +72 -0
  42. package/dist/connections/oauth/client.d.ts +84 -0
  43. package/dist/connections/oauth/client.js +141 -0
  44. package/dist/connections/oauth/index.d.ts +166 -0
  45. package/dist/connections/oauth/index.js +226 -0
  46. package/dist/connections/oauth/service.d.ts +90 -0
  47. package/dist/connections/oauth/service.js +132 -0
  48. package/dist/connections/pause.d.ts +68 -0
  49. package/dist/connections/pause.js +97 -0
  50. package/dist/connections/resolver.d.ts +59 -0
  51. package/dist/connections/resolver.js +138 -0
  52. package/dist/connections/resume.d.ts +64 -0
  53. package/dist/connections/resume.js +99 -0
  54. package/dist/connections/vault-cipher.d.ts +95 -0
  55. package/dist/connections/vault-cipher.js +206 -0
  56. package/dist/core/content-parts.d.ts +18 -2
  57. package/dist/core/content-parts.js +1 -0
  58. package/dist/core/context.d.ts +25 -0
  59. package/dist/core/errors.d.ts +1 -1
  60. package/dist/core/errors.js +15 -0
  61. package/dist/core/events.d.ts +44 -2
  62. package/dist/core/events.js +31 -2
  63. package/dist/core/validation.js +19 -0
  64. package/dist/entries/connections.d.ts +10 -0
  65. package/dist/entries/connections.js +10 -0
  66. package/dist/entries/knowledge.d.ts +8 -0
  67. package/dist/entries/knowledge.js +8 -0
  68. package/dist/entries/mcp-server.d.ts +9 -0
  69. package/dist/entries/mcp-server.js +9 -0
  70. package/dist/entries/runtime.d.ts +10 -0
  71. package/dist/entries/runtime.js +8 -0
  72. package/dist/entries/testing.d.ts +28 -0
  73. package/dist/entries/testing.js +28 -0
  74. package/dist/graphql/resolvers.d.ts +3 -1
  75. package/dist/graphql/resolvers.js +14 -0
  76. package/dist/knowledge/communities.d.ts +166 -0
  77. package/dist/knowledge/communities.js +377 -0
  78. package/dist/knowledge/graph-global.d.ts +153 -0
  79. package/dist/knowledge/graph-global.js +246 -0
  80. package/dist/knowledge/graph-retrieval.d.ts +150 -0
  81. package/dist/knowledge/graph-retrieval.js +303 -0
  82. package/dist/knowledge/graph.d.ts +223 -0
  83. package/dist/knowledge/graph.js +369 -0
  84. package/dist/knowledge/index.d.ts +24 -0
  85. package/dist/knowledge/index.js +36 -1
  86. package/dist/knowledge/retrieval.d.ts +26 -1
  87. package/dist/knowledge/retrieval.js +77 -1
  88. package/dist/mcp-server/index.d.ts +168 -0
  89. package/dist/mcp-server/index.js +175 -0
  90. package/dist/models/extraction.d.ts +73 -0
  91. package/dist/models/extraction.js +118 -0
  92. package/dist/models/index.d.ts +40 -1
  93. package/dist/models/index.js +22 -1
  94. package/dist/models/pricing.d.ts +2 -0
  95. package/dist/models/pricing.js +17 -1
  96. package/dist/models/provider-factory.js +9 -7
  97. package/dist/models/streaming.d.ts +89 -0
  98. package/dist/models/streaming.js +179 -2
  99. package/dist/persistence/index.d.ts +230 -0
  100. package/dist/persistence/index.js +2 -0
  101. package/dist/runtime/index.d.ts +1 -1
  102. package/dist/runtime/index.js +12 -0
  103. package/dist/runtime/worker.d.ts +36 -1
  104. package/dist/runtime/worker.js +37 -4
  105. package/dist/security/checklist.js +46 -0
  106. package/dist/server/bin.d.ts +72 -0
  107. package/dist/server/bin.js +304 -0
  108. package/dist/server/boot.d.ts +20 -2
  109. package/dist/server/boot.js +29 -3
  110. package/dist/server/cli-worker.js +6 -3
  111. package/dist/server/cli.d.ts +13 -0
  112. package/dist/server/cli.js +10 -5
  113. package/dist/server/config.d.ts +11 -1
  114. package/dist/server/config.js +18 -0
  115. package/dist/server/doctor.d.ts +85 -0
  116. package/dist/server/doctor.js +280 -0
  117. package/dist/server/pool.d.ts +47 -0
  118. package/dist/server/pool.js +73 -0
  119. package/dist/skills/context.d.ts +61 -0
  120. package/dist/skills/context.js +107 -0
  121. package/dist/skills/index.d.ts +1 -0
  122. package/dist/skills/index.js +1 -0
  123. package/dist/telemetry/spans.js +5 -0
  124. package/dist/testing/conformance/artifact-exports.d.ts +20 -0
  125. package/dist/testing/conformance/artifact-exports.js +172 -0
  126. package/dist/testing/conformance/artifacts.d.ts +11 -0
  127. package/dist/testing/conformance/artifacts.js +316 -0
  128. package/dist/testing/conformance/audio.d.ts +38 -0
  129. package/dist/testing/conformance/audio.js +145 -0
  130. package/dist/testing/conformance/capability.d.ts +23 -0
  131. package/dist/testing/conformance/capability.js +27 -0
  132. package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
  133. package/dist/testing/conformance/checkpoint-store.js +72 -0
  134. package/dist/testing/conformance/connections.d.ts +14 -0
  135. package/dist/testing/conformance/connections.js +171 -0
  136. package/dist/testing/conformance/conversation-store.d.ts +10 -0
  137. package/dist/testing/conformance/conversation-store.js +60 -0
  138. package/dist/testing/conformance/evaluation.d.ts +10 -0
  139. package/dist/testing/conformance/evaluation.js +295 -0
  140. package/dist/testing/conformance/files.d.ts +19 -0
  141. package/dist/testing/conformance/files.js +454 -0
  142. package/dist/testing/conformance/flows.d.ts +16 -0
  143. package/dist/testing/conformance/flows.js +193 -0
  144. package/dist/testing/conformance/graph.d.ts +22 -0
  145. package/dist/testing/conformance/graph.js +500 -0
  146. package/dist/testing/conformance/hitl.d.ts +25 -0
  147. package/dist/testing/conformance/hitl.js +523 -0
  148. package/dist/testing/conformance/index.d.ts +163 -0
  149. package/dist/testing/conformance/index.js +321 -0
  150. package/dist/testing/conformance/invariants.d.ts +23 -0
  151. package/dist/testing/conformance/invariants.js +80 -0
  152. package/dist/testing/conformance/knowledge.d.ts +41 -0
  153. package/dist/testing/conformance/knowledge.js +592 -0
  154. package/dist/testing/conformance/parents.d.ts +50 -0
  155. package/dist/testing/conformance/parents.js +39 -0
  156. package/dist/testing/conformance/rate-limit.d.ts +25 -0
  157. package/dist/testing/conformance/rate-limit.js +71 -0
  158. package/dist/testing/conformance/records.d.ts +36 -0
  159. package/dist/testing/conformance/records.js +400 -0
  160. package/dist/testing/conformance/rollups.d.ts +30 -0
  161. package/dist/testing/conformance/rollups.js +425 -0
  162. package/dist/testing/conformance/run-coordinator.d.ts +27 -0
  163. package/dist/testing/conformance/run-coordinator.js +150 -0
  164. package/dist/testing/conformance/run-event-log.d.ts +9 -0
  165. package/dist/testing/conformance/run-event-log.js +138 -0
  166. package/dist/testing/conformance/run-store.d.ts +12 -0
  167. package/dist/testing/conformance/run-store.js +294 -0
  168. package/dist/testing/conformance/session-state.d.ts +38 -0
  169. package/dist/testing/conformance/session-state.js +198 -0
  170. package/dist/testing/conformance/usage-limits.d.ts +10 -0
  171. package/dist/testing/conformance/usage-limits.js +309 -0
  172. package/dist/testing/conformance.d.ts +8 -0
  173. package/dist/testing/conformance.js +8 -0
  174. package/dist/testing/memory-backend.d.ts +39 -0
  175. package/dist/testing/memory-backend.js +41 -0
  176. package/dist/testing/pglite.d.ts +55 -0
  177. package/dist/testing/pglite.js +78 -0
  178. package/dist/testing/stub-model.d.ts +58 -0
  179. package/dist/testing/stub-model.js +71 -0
  180. package/dist/testing/supabase-storage-double.d.ts +35 -0
  181. package/dist/testing/supabase-storage-double.js +101 -0
  182. package/dist/toolkit/http.d.ts +27 -0
  183. package/dist/toolkit/http.js +48 -0
  184. package/dist/toolkit/index.d.ts +27 -1
  185. package/dist/toolkit/index.js +24 -1
  186. package/dist/toolkit/mime.d.ts +97 -0
  187. package/dist/toolkit/mime.js +233 -0
  188. package/dist/toolkit/ssrf.d.ts +139 -0
  189. package/dist/toolkit/ssrf.js +349 -0
  190. package/dist/toolkit/vendor.d.ts +91 -0
  191. package/dist/toolkit/vendor.js +153 -0
  192. package/dist/tools/credentials.d.ts +265 -6
  193. package/dist/tools/credentials.js +318 -3
  194. package/dist/tools/define.d.ts +14 -0
  195. package/dist/tools/define.js +3 -0
  196. package/dist/tools/index.d.ts +21 -0
  197. package/dist/tools/index.js +7 -0
  198. package/dist/tools/library/audio.d.ts +63 -0
  199. package/dist/tools/library/audio.js +148 -0
  200. package/dist/tools/library/index.d.ts +8 -0
  201. package/dist/tools/library/index.js +7 -0
  202. package/dist/tools/registry.d.ts +33 -0
  203. package/dist/tools/registry.js +72 -2
  204. package/dist/usage/index.d.ts +21 -0
  205. package/dist/usage/index.js +1 -0
  206. package/dist/usage/rate-limit.d.ts +122 -0
  207. package/dist/usage/rate-limit.js +131 -0
  208. package/dist/usage/recorder.js +3 -0
  209. package/package.json +29 -2
@@ -0,0 +1,369 @@
1
+ /**
2
+ * The knowledge graph — REQ-064 (#270), task #271.
3
+ *
4
+ * Retrieval finds the chunks that *look like* the question. That cannot answer "which teams depend on the retry
5
+ * budget?", because no chunk says so: the fact is spread across a dozen documents, each naming one team and one
6
+ * dependency. This module builds the structure that does — entities, the relationships between them, and where
7
+ * each came from.
8
+ *
9
+ * Nothing here retrieves anything. #273 and #274 do that; this produces the graph and, just as importantly,
10
+ * proves it costs nothing when nobody asked for it.
11
+ *
12
+ * ## Three properties everything else rests on
13
+ *
14
+ * **Off costs nothing.** The extractor is optional in the dependencies and gated twice more at run time. A
15
+ * deployment that never enables GraphRAG supplies no extractor, makes no model calls, and writes no graph rows
16
+ * — measured by a test that counts calls rather than by reading this paragraph.
17
+ *
18
+ * **Determinism.** The same corpus produces byte-identical graphs twice. This is not tidiness: #275 measures
19
+ * GraphRAG against a fixed baseline, and a graph that shifts between runs cannot be measured, cannot be
20
+ * debugged, and cannot have its incremental rebuild (#272) verified. Every ordering here is explicit, and
21
+ * entity resolution is rule-based rather than similarity-based for exactly this reason — embedding similarity
22
+ * is not stable across model versions.
23
+ *
24
+ * **Provenance.** Every entity and edge records its chunks. The retriever will present graph-derived material
25
+ * as citable, so an untraceable claim is one the model states as though a document said it.
26
+ *
27
+ * ## What extraction is allowed to do to us
28
+ *
29
+ * Extraction is a model call, so it can return prose, malformed JSON, entities with no names, or an edge
30
+ * between two things it never mentioned. **None of that may corrupt the graph or fail the index.** A chunk
31
+ * whose extraction is unusable simply contributes nothing — it stays embedded and findable by every existing
32
+ * mode, and the graph is the only thing missing. `sanitiseExtraction` is where that judgement lives, and it is
33
+ * deliberately strict: an edge naming an entity that was not extracted is dropped rather than repaired,
34
+ * because repairing it would invent a provenance nobody asserted.
35
+ */
36
+ /** Words dropped from the front of a name during normalisation. Articles only — see `normaliseName`. */
37
+ const LEADING_ARTICLES = ["the", "a", "an"];
38
+ /**
39
+ * A name reduced to what makes two spellings the same thing.
40
+ *
41
+ * Case, whitespace, separators and a leading article. That gets "the retry budget", "Retry Budget" and
42
+ * "retry-budget" to one entity, which is the merge AC-4 names.
43
+ *
44
+ * **What it deliberately does not do** is anything cleverer. Stemming would merge "billing" with "bill";
45
+ * embedding similarity would merge whatever the model of the week thinks is close and would stop being
46
+ * reproducible the moment that model changed. Both would make the graph unmeasurable, and the second would
47
+ * make it undebuggable too.
48
+ *
49
+ * Parenthetical qualifiers survive as words, so "Ana (engineering)" and "Ana (sales)" stay distinct — which
50
+ * AC-4 asks for, and which a punctuation-stripping rule gets right only by accident. It is worth being explicit
51
+ * that this is *why* punctuation becomes a space rather than nothing.
52
+ */
53
+ export const normaliseName = (raw) => {
54
+ const collapsed = raw
55
+ .normalize("NFKD")
56
+ // Diacritics dropped so "Zurich" and "Zürich" meet. Same reasoning as case.
57
+ .replace(/[̀-ͯ]/g, "")
58
+ .toLowerCase()
59
+ // A separator becomes a space, never nothing: joining "retry" and "budget" into "retrybudget" would fail to
60
+ // match the spaced form, and dropping "(engineering)" entirely would merge two different people.
61
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
62
+ .trim()
63
+ .replace(/\s+/g, " ");
64
+ const words = collapsed.split(" ").filter((word) => word !== "");
65
+ // Only when something follows it: "The" alone is a name, however unlikely, and stripping it leaves nothing.
66
+ if (words.length > 1 && LEADING_ARTICLES.includes(words[0])) {
67
+ return words.slice(1).join(" ");
68
+ }
69
+ return words.join(" ");
70
+ };
71
+ /** An entity type, normalised the same way but without the article rule — a type is one word in practice. */
72
+ export const normaliseType = (raw) => raw
73
+ .toLowerCase()
74
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
75
+ .replace(/^-+|-+$/g, "") || "unknown";
76
+ /**
77
+ * The identity of an entity: its type and its normalised name.
78
+ *
79
+ * Readable rather than hashed, deliberately. `concept:retry budget` in a log, an error message or a database
80
+ * row tells whoever is reading it what went wrong; `sha256:9f2a…` tells them to go and write a script. The id
81
+ * is not a secret and its length is not a problem at this scale.
82
+ *
83
+ * Type participates because a person and a project can share a name and are not the same thing.
84
+ */
85
+ export const entityId = (type, name) => `${normaliseType(type)}:${normaliseName(name)}`;
86
+ /** The identity of an edge. Direction is part of it: "A depends on B" is not "B depends on A". */
87
+ export const relationshipId = (fromId, type, toId) => `${fromId}|${normaliseType(type)}|${toId}`;
88
+ const asText = (value) => (typeof value === "string" ? value.trim() : "");
89
+ /**
90
+ * How long a description may be before it is a chunk rather than a description.
91
+ *
92
+ * A model asked for a short description sometimes returns the paragraph. Truncating rather than refusing keeps
93
+ * the entity — the name and the edges are the valuable part — and stops one verbose extraction from dominating
94
+ * the storage of a whole corpus.
95
+ */
96
+ export const MAX_DESCRIPTION_CHARS = 480;
97
+ const clamp = (text) => text.length <= MAX_DESCRIPTION_CHARS ? text : `${text.slice(0, MAX_DESCRIPTION_CHARS - 1).trimEnd()}…`;
98
+ /**
99
+ * One chunk's raw extraction, turned into rows that can be trusted — or into nothing.
100
+ *
101
+ * Total: any input produces a valid contribution, possibly empty. That is what makes AC-7 hold everywhere at
102
+ * once rather than at each call site.
103
+ *
104
+ * The strict part is edges. An extractor routinely names an endpoint it did not extract — a pronoun it
105
+ * resolved, a thing it inferred — and the tempting fix is to create the missing entity. That invents an entity
106
+ * with a provenance nobody asserted, which is the exact failure provenance exists to prevent. So the edge is
107
+ * dropped, and the chunk keeps whatever else it gave us.
108
+ */
109
+ export const sanitiseExtraction = (chunkId, raw) => {
110
+ const entities = new Map();
111
+ for (const candidate of Array.isArray(raw.entities) ? raw.entities : []) {
112
+ const name = asText(candidate?.name);
113
+ const type = asText(candidate?.type) || "concept";
114
+ if (name === "" || normaliseName(name) === "")
115
+ continue;
116
+ const id = entityId(type, name);
117
+ const description = clamp(asText(candidate?.description));
118
+ const existing = entities.get(id);
119
+ if (existing === undefined) {
120
+ entities.set(id, { name, type, ...(description === "" ? {} : { description }) });
121
+ continue;
122
+ }
123
+ // The same entity named twice in one chunk: keep the longer description, and the name that sorts first so
124
+ // the choice does not depend on extraction order.
125
+ const better = description.length > (existing.description ?? "").length ? description : existing.description;
126
+ entities.set(id, {
127
+ name: existing.name < name ? existing.name : name,
128
+ type: existing.type,
129
+ ...(better === undefined || better === "" ? {} : { description: better }),
130
+ });
131
+ }
132
+ const relationships = new Map();
133
+ for (const candidate of Array.isArray(raw.relationships) ? raw.relationships : []) {
134
+ const from = asText(candidate?.from);
135
+ const to = asText(candidate?.to);
136
+ const type = asText(candidate?.type) || "related-to";
137
+ if (from === "" || to === "")
138
+ continue;
139
+ // Endpoints are matched by *normalised name across any type*, because an extractor names an entity in a
140
+ // relationship without repeating its type and a type-qualified lookup would then miss every edge.
141
+ const fromId = [...entities.keys()].find((id) => id.slice(id.indexOf(":") + 1) === normaliseName(from));
142
+ const toId = [...entities.keys()].find((id) => id.slice(id.indexOf(":") + 1) === normaliseName(to));
143
+ if (fromId === undefined || toId === undefined)
144
+ continue; // See the note above: dropped, never invented.
145
+ if (fromId === toId)
146
+ continue; // A self-edge carries no information and clutters every traversal.
147
+ const description = clamp(asText(candidate?.description));
148
+ const id = relationshipId(fromId, type, toId);
149
+ const existing = relationships.get(id);
150
+ if (existing === undefined) {
151
+ relationships.set(id, { fromId, toId, type, ...(description === "" ? {} : { description }) });
152
+ }
153
+ else if (description.length > (existing.description ?? "").length) {
154
+ relationships.set(id, { ...existing, description });
155
+ }
156
+ }
157
+ return {
158
+ entities: [...entities.entries()]
159
+ .map(([id, entity]) => ({
160
+ id,
161
+ name: entity.name,
162
+ type: normaliseType(entity.type),
163
+ ...(entity.description === undefined ? {} : { description: entity.description }),
164
+ surfaceForms: [entity.name],
165
+ provenance: [chunkId],
166
+ }))
167
+ // Sorted here, not by the caller: determinism is a property of this function's output, so it cannot be
168
+ // forgotten at one of several call sites.
169
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
170
+ relationships: [...relationships.entries()]
171
+ .map(([id, edge]) => ({
172
+ id,
173
+ fromId: edge.fromId,
174
+ toId: edge.toId,
175
+ type: normaliseType(edge.type),
176
+ ...(edge.description === undefined ? {} : { description: edge.description }),
177
+ weight: 1,
178
+ provenance: [chunkId],
179
+ }))
180
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
181
+ };
182
+ };
183
+ const uniqueSorted = (values) => [...new Set(values)].sort();
184
+ /**
185
+ * Many chunks' contributions, merged into one source's contribution.
186
+ *
187
+ * Where surface forms accumulate and edge weights are counted. The canonical `name` is the one that sorts
188
+ * first among everything merged — an arbitrary rule, chosen because it is *stable*: "first seen" would depend
189
+ * on chunk order, and chunk order is a thing that can change without the document changing.
190
+ */
191
+ export const mergeContributions = (parts) => {
192
+ const entities = new Map();
193
+ for (const part of parts) {
194
+ for (const entity of part.entities) {
195
+ const existing = entities.get(entity.id);
196
+ if (existing === undefined) {
197
+ entities.set(entity.id, entity);
198
+ continue;
199
+ }
200
+ const description = (entity.description ?? "").length > (existing.description ?? "").length ? entity.description : existing.description;
201
+ entities.set(entity.id, {
202
+ id: entity.id,
203
+ name: existing.name < entity.name ? existing.name : entity.name,
204
+ type: existing.type,
205
+ ...(description === undefined ? {} : { description }),
206
+ surfaceForms: uniqueSorted([...existing.surfaceForms, ...entity.surfaceForms]),
207
+ provenance: uniqueSorted([...existing.provenance, ...entity.provenance]),
208
+ });
209
+ }
210
+ }
211
+ const relationships = new Map();
212
+ for (const part of parts) {
213
+ for (const edge of part.relationships) {
214
+ const existing = relationships.get(edge.id);
215
+ if (existing === undefined) {
216
+ relationships.set(edge.id, edge);
217
+ continue;
218
+ }
219
+ const description = (edge.description ?? "").length > (existing.description ?? "").length ? edge.description : existing.description;
220
+ const provenance = uniqueSorted([...existing.provenance, ...edge.provenance]);
221
+ relationships.set(edge.id, {
222
+ ...existing,
223
+ ...(description === undefined ? {} : { description }),
224
+ // Chunks that asserted it, not times it was seen — so re-reading one chunk cannot inflate a weight.
225
+ weight: provenance.length,
226
+ provenance,
227
+ });
228
+ }
229
+ }
230
+ // An edge whose endpoints did not survive the merge is dropped, for the same reason `sanitiseExtraction`
231
+ // drops one: the alternative is an edge pointing at nothing.
232
+ const kept = [...relationships.values()].filter((edge) => entities.has(edge.fromId) && entities.has(edge.toId));
233
+ return {
234
+ entities: [...entities.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
235
+ relationships: kept.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
236
+ };
237
+ };
238
+ /**
239
+ * Extraction runs one chunk at a time by default.
240
+ *
241
+ * Sequential rather than parallel because the cost is the point: a corpus of 603 chunks is 603 model calls, and
242
+ * firing them concurrently is how a re-index becomes a rate-limit incident. A deployment that wants throughput
243
+ * raises this deliberately, having read the number.
244
+ */
245
+ export const DEFAULT_EXTRACTION_CONCURRENCY = 1;
246
+ export const createGraphIndexer = (deps) => {
247
+ const now = deps.now ?? (() => Date.now());
248
+ const log = deps.log ?? (() => { });
249
+ return {
250
+ extractorId: deps.extractor.id,
251
+ /**
252
+ * Whether this source would be graph-indexed — both switches, in the cheap order.
253
+ *
254
+ * The tenant switch first, so a deployment with GraphRAG off does one settings read and stops, rather than
255
+ * one read per source. AC-2's "flag is inert while the tenant switch is off" is this ordering: the source
256
+ * flag is never even consulted, and it stays stored for the day the tenant switch flips.
257
+ */
258
+ async shouldIndex(context, source) {
259
+ const settings = await deps.store.getSettings({ tenantId: context.tenantId });
260
+ if (!settings.enabled)
261
+ return false;
262
+ return deps.store.isSourceEnabled({ tenantId: context.tenantId, ...source });
263
+ },
264
+ /**
265
+ * Extract one source's chunks into the graph, replacing whatever that source contributed before.
266
+ *
267
+ * The caller has already decided this should run — `indexSource` calls `shouldIndex` first — so this does
268
+ * not re-check. Keeping the decision in one place is what makes "off costs nothing" a property of one
269
+ * function rather than a convention two functions share.
270
+ */
271
+ async indexSource(context, input) {
272
+ const started = now();
273
+ const concurrency = Math.max(1, input.concurrency ?? DEFAULT_EXTRACTION_CONCURRENCY);
274
+ const parts = [];
275
+ let extractionCalls = 0;
276
+ let inputTokens = 0;
277
+ let outputTokens = 0;
278
+ let unusableChunks = 0;
279
+ const runOne = async (chunk) => {
280
+ extractionCalls += 1;
281
+ try {
282
+ const { extraction, usage } = await deps.extractor.extract(chunk);
283
+ inputTokens += usage?.inputTokens ?? 0;
284
+ outputTokens += usage?.outputTokens ?? 0;
285
+ const contribution = sanitiseExtraction(chunk.id, extraction ?? {});
286
+ if (contribution.entities.length === 0)
287
+ unusableChunks += 1;
288
+ return contribution;
289
+ }
290
+ catch (error) {
291
+ /**
292
+ * AC-7. A model call can fail or return prose, and neither may fail the index.
293
+ *
294
+ * Swallowed here rather than propagated because the chunk is *already embedded and findable* — every
295
+ * existing retrieval mode works on it, and the graph is the only thing missing. Failing the index
296
+ * would take the working part down with the optional one.
297
+ */
298
+ unusableChunks += 1;
299
+ log("graph extraction failed for a chunk", {
300
+ chunkId: chunk.id,
301
+ reason: error instanceof Error ? error.message : String(error),
302
+ });
303
+ return { entities: [], relationships: [] };
304
+ }
305
+ };
306
+ for (let offset = 0; offset < input.chunks.length; offset += concurrency) {
307
+ const window = input.chunks.slice(offset, offset + concurrency);
308
+ // Order-independent by construction — `mergeContributions` sorts — but collected in order anyway, so a
309
+ // log read alongside the corpus makes sense.
310
+ parts.push(...(await Promise.all(window.map(runOne))));
311
+ }
312
+ const contribution = mergeContributions(parts);
313
+ const written = await deps.store.replaceSourceGraph({
314
+ tenantId: context.tenantId,
315
+ sourceType: input.sourceType,
316
+ sourceId: input.sourceId,
317
+ contribution,
318
+ });
319
+ const result = {
320
+ ran: true,
321
+ entities: written.entities,
322
+ relationships: written.relationships,
323
+ pruned: written.pruned,
324
+ extractionCalls,
325
+ inputTokens,
326
+ outputTokens,
327
+ unusableChunks,
328
+ elapsedMs: now() - started,
329
+ };
330
+ if (unusableChunks > 0)
331
+ log("some chunks contributed nothing to the graph", {
332
+ sourceId: input.sourceId,
333
+ unusableChunks,
334
+ of: input.chunks.length,
335
+ });
336
+ return result;
337
+ },
338
+ };
339
+ };
340
+ /** What `indexSource` reports when the graph was not touched. Every counter zero, and `ran` says why. */
341
+ export const GRAPH_NOT_RUN = {
342
+ ran: false,
343
+ entities: 0,
344
+ relationships: 0,
345
+ pruned: 0,
346
+ extractionCalls: 0,
347
+ inputTokens: 0,
348
+ outputTokens: 0,
349
+ unusableChunks: 0,
350
+ elapsedMs: 0,
351
+ };
352
+ /**
353
+ * The default `EntityExtractor`: one model call per chunk — AC-2.
354
+ *
355
+ * Thin on purpose. The provider call lives in `models/extraction.ts` behind boundary rule R3, so this is the
356
+ * adapter between that function and the port — which means a deployment can replace the extractor entirely
357
+ * (a smaller model, a local one, a rules-based one for a known corpus) without touching the pipeline.
358
+ *
359
+ * `model` is a factory rather than a value because a `LanguageModel` is resolved from a policy per tenant, and
360
+ * a single resolved model captured at construction would be the same one for every tenant in the deployment.
361
+ */
362
+ export const createModelEntityExtractor = (deps) => ({
363
+ id: deps.id ?? "model",
364
+ async extract(chunk) {
365
+ const { extraction, usage } = await deps.extract(chunk.content);
366
+ return { extraction, usage };
367
+ },
368
+ });
369
+ //# sourceMappingURL=graph.js.map
@@ -16,6 +16,7 @@ import type { TenantId } from "../core/ids.js";
16
16
  import type { DocumentBlock } from "../documents/index.js";
17
17
  import type { EmbeddingModelRef, KnowledgeSourceType, KnowledgeStore } from "../persistence/index.js";
18
18
  import { type ChunkingLimits } from "./chunking.js";
19
+ import { type GraphIndexResult, type GraphIndexer } from "./graph.js";
19
20
  /**
20
21
  * How long newly added material may take to become findable — AC-4.
21
22
  *
@@ -59,6 +60,14 @@ export type IndexResult = {
59
60
  /** Wall-clock, so the freshness target can be measured rather than assumed. */
60
61
  readonly elapsedMs: number;
61
62
  readonly model: EmbeddingModelRef;
63
+ /**
64
+ * What the graph cost, when GraphRAG ran — REQ-064 (#270), task #271.
65
+ *
66
+ * Always present, never optional, and `ran: false` with every counter zero when it did not. An optional field
67
+ * would let a caller read "no graph work" and "GraphRAG is off" as the same thing, and they are not: the
68
+ * first is a source with nothing extractable, the second is a deployment that never asked.
69
+ */
70
+ readonly graph: GraphIndexResult;
62
71
  };
63
72
  export type EmbeddingPipelineDeps = {
64
73
  readonly knowledge: KnowledgeStore;
@@ -69,6 +78,17 @@ export type EmbeddingPipelineDeps = {
69
78
  /** Injectable so a test measures elapsed time without waiting for it. */
70
79
  readonly now?: () => number;
71
80
  readonly log?: (message: string, detail?: Readonly<Record<string, unknown>>) => void;
81
+ /**
82
+ * The graph indexer — REQ-064 (#270). **Absent means GraphRAG cannot run at all.**
83
+ *
84
+ * Optional at the type level rather than switched at run time, so a deployment that never enables it supplies
85
+ * no `EntityExtractor` and therefore configures no language model to index a document. That is the outermost
86
+ * of the three gates behind AC-4's "off costs nothing": absent here, off for the tenant, unflagged for the
87
+ * source. The first is free, and the second costs one settings read.
88
+ */
89
+ readonly graph?: GraphIndexer;
90
+ /** Chunks extracted concurrently. See `DEFAULT_EXTRACTION_CONCURRENCY` — the default is 1, deliberately. */
91
+ readonly graphConcurrency?: number;
72
92
  };
73
93
  /** Deterministic chunk id, so re-indexing the same source overwrites its own rows rather than duplicating. */
74
94
  export declare const chunkId: (sourceType: string, sourceId: string, index: number) => string;
@@ -117,4 +137,8 @@ export type EmbeddingPipeline = ReturnType<typeof createEmbeddingPipeline>;
117
137
  export * from "./chunking.js";
118
138
  export * from "./retrieval.js";
119
139
  export * from "./navigate.js";
140
+ export * from "./graph.js";
141
+ export * from "./graph-retrieval.js";
142
+ export * from "./communities.js";
143
+ export * from "./graph-global.js";
120
144
  //# sourceMappingURL=index.d.ts.map
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { AgentPlatformError } from "../core/errors.js";
16
16
  import { DEFAULT_CHUNKING_LIMITS, chunkDocument } from "./chunking.js";
17
+ import { GRAPH_NOT_RUN } from "./graph.js";
17
18
  /**
18
19
  * How long newly added material may take to become findable — AC-4.
19
20
  *
@@ -54,12 +55,24 @@ export const createEmbeddingPipeline = (deps) => {
54
55
  sourceType: input.sourceType,
55
56
  sourceId: input.sourceId,
56
57
  });
58
+ // The graph goes with the content. A document extracted down to nothing must not leave entities
59
+ // asserting what it used to say — the same reasoning as removing its chunks.
60
+ let graph = GRAPH_NOT_RUN;
61
+ if (deps.graph !== undefined && (await deps.graph.shouldIndex(context, input))) {
62
+ const pruned = await deps.graph.indexSource(context, {
63
+ sourceType: input.sourceType,
64
+ sourceId: input.sourceId,
65
+ chunks: [],
66
+ });
67
+ graph = pruned;
68
+ }
57
69
  return {
58
70
  written: 0,
59
71
  removed: cleared.removed,
60
72
  batches: 0,
61
73
  elapsedMs: now() - started,
62
74
  model: deps.embeddings.model,
75
+ graph,
63
76
  };
64
77
  }
65
78
  const embedded = [];
@@ -98,6 +111,24 @@ export const createEmbeddingPipeline = (deps) => {
98
111
  sourceId: input.sourceId,
99
112
  chunks: embedded,
100
113
  });
114
+ /**
115
+ * The graph, after the chunks are written and only if asked — AC-1, AC-4.
116
+ *
117
+ * After, because the graph's provenance is chunk ids: writing edges that point at chunks which then fail
118
+ * to persist would leave the graph asserting sources that are not there.
119
+ *
120
+ * `shouldIndex` is one settings read when GraphRAG is off for the tenant, and zero model calls. When
121
+ * `deps.graph` is absent it is not even that.
122
+ */
123
+ let graph = GRAPH_NOT_RUN;
124
+ if (deps.graph !== undefined && (await deps.graph.shouldIndex(context, input))) {
125
+ graph = await deps.graph.indexSource(context, {
126
+ sourceType: input.sourceType,
127
+ sourceId: input.sourceId,
128
+ chunks: embedded.map((chunk) => ({ id: chunk.id, content: chunk.content })),
129
+ ...(deps.graphConcurrency === undefined ? {} : { concurrency: deps.graphConcurrency }),
130
+ });
131
+ }
101
132
  const elapsedMs = now() - started;
102
133
  if (elapsedMs > FRESHNESS_TARGET_MS)
103
134
  // Reported rather than thrown: the material *is* indexed, and the useful action is to know the target
@@ -107,7 +138,7 @@ export const createEmbeddingPipeline = (deps) => {
107
138
  elapsedMs,
108
139
  targetMs: FRESHNESS_TARGET_MS,
109
140
  });
110
- return { ...written, batches, elapsedMs, model: deps.embeddings.model };
141
+ return { ...written, batches, elapsedMs, model: deps.embeddings.model, graph };
111
142
  },
112
143
  /**
113
144
  * One page of a re-index — AC-5.
@@ -164,4 +195,8 @@ export const createEmbeddingPipeline = (deps) => {
164
195
  export * from "./chunking.js";
165
196
  export * from "./retrieval.js";
166
197
  export * from "./navigate.js";
198
+ export * from "./graph.js";
199
+ export * from "./graph-retrieval.js";
200
+ export * from "./communities.js";
201
+ export * from "./graph-global.js";
167
202
  //# sourceMappingURL=index.js.map
@@ -41,6 +41,8 @@ import type { TenantId } from "../core/ids.js";
41
41
  import type { KeywordIndex, KnowledgeChunk, KnowledgeSourceType, VectorIndex } from "../persistence/index.js";
42
42
  import type { EmbeddingProvider } from "./index.js";
43
43
  import type { Navigator } from "./navigate.js";
44
+ import type { GraphLocalSearch } from "./graph-retrieval.js";
45
+ import type { GraphGlobalSearch } from "./graph-global.js";
44
46
  /** The rank-fusion constant. See the note above on why 60 and why rank rather than score. */
45
47
  export declare const RRF_K = 60;
46
48
  /** How many candidates each signal contributes before fusion. */
@@ -64,7 +66,14 @@ export declare const DEFAULT_RELEVANCE_FLOOR = 0.4;
64
66
  *
65
67
  * See `navigate.ts` for what it is and `docs/26-retrieval-quality.md` for what it scored.
66
68
  */
67
- export type RetrievalMode = "semantic" | "keyword" | "hybrid" | "navigate";
69
+ /**
70
+ * `graph-local` joins the union in REQ-064 (#270), task #273.
71
+ *
72
+ * A closed union read in several places, so a new member is threaded rather than cast past — the same
73
+ * discipline #219 applied when it added `not-configured`. Like `navigate`, it is *delegated whole*: it shares
74
+ * no step with the fusion path, because it answers a different kind of question.
75
+ */
76
+ export type RetrievalMode = "semantic" | "keyword" | "hybrid" | "navigate" | "graph-local" | "graph-global";
68
77
  /** What a citation needs, derived from a hit so there is one shape rather than each caller's own (AC-6). */
69
78
  export type SourceReference = {
70
79
  readonly sourceType: KnowledgeSourceType;
@@ -149,6 +158,22 @@ export type RetrieverDeps = {
149
158
  * mechanism, which is the only way this spike could have done harm.
150
159
  */
151
160
  readonly navigator?: Navigator;
161
+ /**
162
+ * Serves `mode: "graph-local"` — REQ-064 (#270), task #273.
163
+ *
164
+ * Optional for the same reason `navigator` is, and its absence is the same named refusal. GraphRAG is opt-in
165
+ * and expensive to index, so a deployment that never enabled it supplies nothing here and asking for the
166
+ * mode says so rather than quietly returning embeddings.
167
+ */
168
+ readonly graphLocal?: GraphLocalSearch;
169
+ /**
170
+ * Serves `mode: "graph-global"` — REQ-064 (#270), task #274.
171
+ *
172
+ * Separate from `graphLocal` rather than one graph dependency, because they are independently useful: a
173
+ * deployment can run entity-centric retrieval without paying for community summaries at all, and #273 landed
174
+ * before #272 for exactly that reason.
175
+ */
176
+ readonly graphGlobal?: GraphGlobalSearch;
152
177
  };
153
178
  export type RetrieveInput = {
154
179
  readonly query: string;
@@ -63,11 +63,17 @@ export const NO_RESULT_REASONS = [
63
63
  "below-threshold",
64
64
  "no-access",
65
65
  /**
66
- * The mode asked for is not wired — task #219.
66
+ * The mode asked for is not wired.
67
67
  *
68
68
  * Its own reason rather than `no-match`, because the two want opposite responses: one says rephrase, this says
69
69
  * a deployment has not configured what you asked for. Falling back to another mode silently would be worse
70
70
  * than either, since the caller would attribute the results to the mode it named.
71
+ *
72
+ * **Live, not a placeholder** — audited for #256 AC-8. Three code paths return it (`navigate` with no
73
+ * navigator, `graph-local` and `graph-global` with no GraphRAG), and three test files assert it. The optional
74
+ * modes are exactly why it has to exist: GraphRAG ships **off by default** on the measured evidence in
75
+ * `docs/29`, so "you asked for a mode this deployment did not switch on" is an ordinary answer rather than an
76
+ * edge case. The comment previously cited task #219 as if it were pending; it closed.
71
77
  */
72
78
  "not-configured",
73
79
  ];
@@ -119,6 +125,76 @@ export const createRetriever = (deps) => {
119
125
  rerankerId: deps.reranker?.id ?? null,
120
126
  async retrieve(context, input) {
121
127
  const mode = input.mode ?? "hybrid";
128
+ /**
129
+ * The graph mode, delegated whole — task #273.
130
+ *
131
+ * Before the `authSubjects` check below only because it does its own: an empty subject list means the
132
+ * same thing here, and the graph search returns an empty result for it rather than traversing first and
133
+ * filtering after. Placed with `navigate` because both are delegated modes rather than fusion inputs.
134
+ */
135
+ if (mode === "graph-local") {
136
+ if (deps.graphLocal === undefined)
137
+ return { found: false, reason: "not-configured", message: NO_RESULT_MESSAGES["not-configured"], mode };
138
+ if (input.authSubjects.length === 0)
139
+ return { found: false, reason: "no-access", message: NO_RESULT_MESSAGES["no-access"], mode };
140
+ const result = await deps.graphLocal.search(context, {
141
+ query: input.query,
142
+ authSubjects: input.authSubjects,
143
+ limit: input.limit,
144
+ ...(input.sourceTypes === undefined ? {} : { sourceTypes: input.sourceTypes }),
145
+ });
146
+ if (result.hits.length === 0)
147
+ /**
148
+ * An honest empty result, and the reason distinguishes the two ways it happens.
149
+ *
150
+ * Nothing in the question named an entity the graph knows is `no-match` — rephrasing might work.
151
+ * Entities matched but reached no readable chunk is also `no-match` rather than `no-access`, because
152
+ * the principal may genuinely have access to a corpus that simply says nothing more.
153
+ */
154
+ return { found: false, reason: "no-match", message: NO_RESULT_MESSAGES["no-match"], mode };
155
+ return {
156
+ found: true,
157
+ mode,
158
+ hits: result.hits.map((hit) => ({
159
+ chunk: hit.chunk,
160
+ score: hit.score,
161
+ signals: ["graph-local"],
162
+ reference: referenceFor(hit.chunk),
163
+ })),
164
+ };
165
+ }
166
+ /**
167
+ * The corpus-level mode — task #274.
168
+ *
169
+ * Its cost ceiling **throws** rather than returning an outcome, and that is deliberate: exceeding it is
170
+ * not "found nothing", it is "this question is too expensive to answer this way", and the two want
171
+ * different responses. `RetrievalOutcome`'s `found: false` reasons are all about the corpus; this is
172
+ * about the request.
173
+ */
174
+ if (mode === "graph-global") {
175
+ if (deps.graphGlobal === undefined)
176
+ return { found: false, reason: "not-configured", message: NO_RESULT_MESSAGES["not-configured"], mode };
177
+ if (input.authSubjects.length === 0)
178
+ return { found: false, reason: "no-access", message: NO_RESULT_MESSAGES["no-access"], mode };
179
+ const result = await deps.graphGlobal.search(context, {
180
+ query: input.query,
181
+ authSubjects: input.authSubjects,
182
+ limit: input.limit,
183
+ ...(input.sourceTypes === undefined ? {} : { sourceTypes: input.sourceTypes }),
184
+ });
185
+ if (result.hits.length === 0)
186
+ return { found: false, reason: "no-match", message: NO_RESULT_MESSAGES["no-match"], mode };
187
+ return {
188
+ found: true,
189
+ mode,
190
+ hits: result.hits.map((hit) => ({
191
+ chunk: hit.chunk,
192
+ score: hit.score,
193
+ signals: ["graph-global"],
194
+ reference: referenceFor(hit.chunk),
195
+ })),
196
+ };
197
+ }
122
198
  // The spike's mode, delegated whole: it shares no step with the fusion path below.
123
199
  if (mode === "navigate") {
124
200
  if (deps.navigator === undefined)