@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,377 @@
1
+ /**
2
+ * Community detection — REQ-064 (#270), task #272.
3
+ *
4
+ * `graph-global` answers "what are the main themes?" by reducing over summaries of clusters. This produces the
5
+ * clusters.
6
+ *
7
+ * ## Why this is implemented here rather than imported
8
+ *
9
+ * `backend` has exactly two runtime dependencies, `ai` and `zod`, and that is a property worth keeping. But the
10
+ * dependency count is the smaller argument. The larger one is **determinism**: Microsoft's GraphRAG uses
11
+ * hierarchical Leiden, and the widely-used implementations of Leiden and Louvain are seeded-random — they
12
+ * shuffle node order to escape local optima, which is good for modularity and fatal here. #275 measures
13
+ * GraphRAG against a fixed baseline, #272's own incremental rebuild is verified by comparing against a
14
+ * previous clustering, and both need the same graph to cluster the same way twice.
15
+ *
16
+ * So this is Louvain with every source of nondeterminism removed:
17
+ *
18
+ * - Nodes are visited in **sorted id order**, never shuffled.
19
+ * - A tie in modularity gain is broken by the **smallest community id**, so equal options resolve the same way.
20
+ * - Aggregation preserves that ordering into the next level.
21
+ *
22
+ * The cost is some modularity — a shuffled Louvain finds slightly better partitions on average. That is a
23
+ * trade worth making for a result that can be measured, diffed and debugged, and it is stated here rather than
24
+ * discovered later by somebody wondering why two runs disagree.
25
+ *
26
+ * Louvain rather than label propagation, which was the other candidate: label propagation is simpler and
27
+ * roughly as fast, but it produces one flat partition and #272 needs **hierarchy**. Louvain's aggregation
28
+ * phases *are* the levels — level 0 is fine-grained, each subsequent level coarser — which is exactly the
29
+ * granularity choice `graph-global` has to make between "what are the main themes" and "what are the themes in
30
+ * observability".
31
+ */
32
+ /**
33
+ * One Louvain pass: move nodes between communities while modularity improves.
34
+ *
35
+ * Returns a map from node id to community label. Deterministic throughout — see the header.
36
+ */
37
+ const localMoving = (nodes, edges) => {
38
+ const community = new Map(nodes.map((node) => [node, node]));
39
+ const adjacency = new Map(nodes.map((node) => [node, []]));
40
+ const degree = new Map(nodes.map((node) => [node, 0]));
41
+ let totalWeight = 0;
42
+ for (const edge of edges) {
43
+ adjacency.get(edge.a)?.push({ neighbour: edge.b, weight: edge.weight });
44
+ adjacency.get(edge.b)?.push({ neighbour: edge.a, weight: edge.weight });
45
+ degree.set(edge.a, (degree.get(edge.a) ?? 0) + edge.weight);
46
+ degree.set(edge.b, (degree.get(edge.b) ?? 0) + edge.weight);
47
+ totalWeight += edge.weight;
48
+ }
49
+ // A graph with no edges is a graph of singletons, and dividing by its total weight would be a NaN that
50
+ // propagates into every gain comparison and produces an arbitrary partition.
51
+ if (totalWeight === 0)
52
+ return community;
53
+ const m2 = 2 * totalWeight;
54
+ const communityDegree = new Map();
55
+ for (const node of nodes)
56
+ communityDegree.set(node, degree.get(node) ?? 0);
57
+ // Bounded rather than "until no change": a cycle between two equal-gain configurations would otherwise spin
58
+ // forever, and the bound costs at most a slightly worse partition.
59
+ const MAX_PASSES = 20;
60
+ for (let pass = 0; pass < MAX_PASSES; pass += 1) {
61
+ let moved = false;
62
+ // Sorted order, every pass. This is the single most important line for determinism.
63
+ for (const node of nodes) {
64
+ const current = community.get(node);
65
+ const nodeDegree = degree.get(node) ?? 0;
66
+ // Weight from this node into each candidate community.
67
+ const into = new Map();
68
+ for (const { neighbour, weight } of adjacency.get(node) ?? []) {
69
+ if (neighbour === node)
70
+ continue;
71
+ const target = community.get(neighbour);
72
+ into.set(target, (into.get(target) ?? 0) + weight);
73
+ }
74
+ // Remove the node from its own community before comparing, or it competes with itself.
75
+ communityDegree.set(current, (communityDegree.get(current) ?? 0) - nodeDegree);
76
+ let best = current;
77
+ let bestGain = (into.get(current) ?? 0) - ((communityDegree.get(current) ?? 0) * nodeDegree) / m2;
78
+ /**
79
+ * Sorted, and **belt-and-braces rather than load-bearing** — worth saying, because a comment claiming
80
+ * more than it does is how a redundant line survives a refactor that made it necessary.
81
+ *
82
+ * Given sorted nodes and sorted edges, adjacency lists are built in a deterministic order, so `into`
83
+ * already iterates deterministically. Sorting here makes the tie-break independent of *that* fact, so a
84
+ * future change to how adjacency is assembled cannot silently reintroduce order sensitivity. Removing it
85
+ * today breaks no test, which is exactly what one would expect.
86
+ */
87
+ for (const target of [...into.keys()].sort()) {
88
+ if (target === current)
89
+ continue;
90
+ const gain = (into.get(target) ?? 0) - ((communityDegree.get(target) ?? 0) * nodeDegree) / m2;
91
+ // Strictly greater, then smallest id: an equal-gain move must resolve the same way every run, and
92
+ // `>` alone would take whichever candidate happened to be visited first.
93
+ if (gain > bestGain || (gain === bestGain && target < best)) {
94
+ best = target;
95
+ bestGain = gain;
96
+ }
97
+ }
98
+ communityDegree.set(best, (communityDegree.get(best) ?? 0) + nodeDegree);
99
+ if (best !== current) {
100
+ community.set(node, best);
101
+ moved = true;
102
+ }
103
+ }
104
+ if (!moved)
105
+ break;
106
+ }
107
+ return community;
108
+ };
109
+ /** Relabels communities by their smallest member, so labels do not depend on which node was seen first. */
110
+ const canonicalise = (assignment) => {
111
+ const members = new Map();
112
+ for (const [node, label] of assignment) {
113
+ const group = members.get(label);
114
+ if (group === undefined)
115
+ members.set(label, [node]);
116
+ else
117
+ group.push(node);
118
+ }
119
+ const canonical = new Map();
120
+ for (const [, group] of members) {
121
+ const label = [...group].sort()[0];
122
+ for (const node of group)
123
+ canonical.set(node, label);
124
+ }
125
+ return canonical;
126
+ };
127
+ const fingerprintOf = (level, entityIds, relationshipIds) => `L${level}|${[...entityIds].sort().join(",")}|${[...relationshipIds].sort().join(",")}`;
128
+ /**
129
+ * How many levels of hierarchy to build.
130
+ *
131
+ * Two is the minimum AC-1 asks for and the useful default: level 0 is fine-grained clusters and level 1 groups
132
+ * them. More levels on a small graph collapse to the same partition repeated, which costs summarisation calls
133
+ * for no new information — so the loop stops early when a level stops merging anything.
134
+ */
135
+ export const DEFAULT_COMMUNITY_LEVELS = 2;
136
+ /**
137
+ * Cluster a graph into a hierarchy of communities.
138
+ *
139
+ * Pure and deterministic: the same entities and relationships always produce the same result, which every test
140
+ * of incremental rebuild depends on.
141
+ *
142
+ * Singletons are kept rather than dropped. An entity nothing links to is a real part of the corpus and a
143
+ * `graph-global` answer that silently omitted every unconnected concept would be quietly wrong about what the
144
+ * corpus contains.
145
+ */
146
+ export const detectCommunities = (input) => {
147
+ const levels = Math.max(1, input.levels ?? DEFAULT_COMMUNITY_LEVELS);
148
+ const out = [];
149
+ // Sorted once, and every derived list keeps that order.
150
+ let nodes = [...input.entityIds].sort();
151
+ if (nodes.length === 0)
152
+ return [];
153
+ const known = new Set(nodes);
154
+ let edges = input.relationships
155
+ .filter((edge) => known.has(edge.fromId) && known.has(edge.toId))
156
+ // Undirected for clustering: "A depends on B" and "B is depended on by A" are the same association, and
157
+ // direction would split a community that is obviously one.
158
+ .map((edge) => ({ a: edge.fromId < edge.toId ? edge.fromId : edge.toId, b: edge.fromId < edge.toId ? edge.toId : edge.fromId, weight: edge.weight }))
159
+ .sort((x, y) => (x.a === y.a ? (x.b < y.b ? -1 : 1) : x.a < y.a ? -1 : 1));
160
+ /** Which original entities each current node stands for. Grows as levels aggregate. */
161
+ let expansion = new Map(nodes.map((node) => [node, [node]]));
162
+ for (let level = 0; level < levels; level += 1) {
163
+ const assignment = canonicalise(localMoving(nodes, edges));
164
+ const grouped = new Map();
165
+ for (const node of nodes) {
166
+ const label = assignment.get(node);
167
+ const group = grouped.get(label);
168
+ if (group === undefined)
169
+ grouped.set(label, [node]);
170
+ else
171
+ group.push(node);
172
+ }
173
+ // A level that changed nothing adds no information and would cost a summarisation call per community.
174
+ if (level > 0 && grouped.size === nodes.length)
175
+ break;
176
+ for (const [label, group] of [...grouped.entries()].sort()) {
177
+ const entityIds = group.flatMap((node) => expansion.get(node) ?? [node]).sort();
178
+ const inside = new Set(entityIds);
179
+ const relationshipIds = input.relationships
180
+ .filter((edge) => inside.has(edge.fromId) && inside.has(edge.toId))
181
+ .map((edge) => edge.id)
182
+ .sort();
183
+ const chunkIds = [
184
+ ...new Set([
185
+ ...entityIds.flatMap((id) => input.chunksOf(id)),
186
+ ...input.relationships.filter((edge) => relationshipIds.includes(edge.id)).flatMap((edge) => edge.provenance),
187
+ ]),
188
+ ].sort();
189
+ out.push({
190
+ // `label` is already the smallest member at this level, but the *expanded* smallest is what identifies
191
+ // the cluster across levels — otherwise a level-1 community and the level-0 one it contains could
192
+ // share an id.
193
+ id: `L${level}:${entityIds[0] ?? label}`,
194
+ level,
195
+ entityIds,
196
+ relationshipIds,
197
+ chunkIds,
198
+ fingerprint: fingerprintOf(level, entityIds, relationshipIds),
199
+ });
200
+ }
201
+ if (grouped.size <= 1)
202
+ break; // Everything is one community; there is nothing coarser to build.
203
+ // Aggregate for the next level: each community becomes a node, edges between them are summed.
204
+ const nextNodes = [...grouped.keys()].sort();
205
+ const nextExpansion = new Map(nextNodes.map((label) => [label, (grouped.get(label) ?? []).flatMap((node) => expansion.get(node) ?? [node]).sort()]));
206
+ const merged = new Map();
207
+ for (const edge of edges) {
208
+ const a = assignment.get(edge.a);
209
+ const b = assignment.get(edge.b);
210
+ if (a === b)
211
+ continue; // Internal edges do not connect communities.
212
+ const key = a < b ? `${a}${b}` : `${b}${a}`;
213
+ merged.set(key, (merged.get(key) ?? 0) + edge.weight);
214
+ }
215
+ nodes = nextNodes;
216
+ expansion = nextExpansion;
217
+ edges = [...merged.entries()]
218
+ .map(([key, weight]) => {
219
+ const [a, b] = key.split("");
220
+ return { a, b, weight };
221
+ })
222
+ .sort((x, y) => (x.a === y.a ? (x.b < y.b ? -1 : 1) : x.a < y.a ? -1 : 1));
223
+ }
224
+ return out;
225
+ };
226
+ export const DEFAULT_MAX_EXCERPTS = 20;
227
+ /**
228
+ * Rebuilds the community hierarchy, and re-summarises **only what changed**.
229
+ *
230
+ * This is where AC-5's "incremental" honestly lives, and it is worth being precise about the split rather than
231
+ * claiming more than is true:
232
+ *
233
+ * - **Clustering is global and cheap.** Community detection is global by nature — one entity moving can change
234
+ * every level — so the whole graph is re-clustered. It is arithmetic over ids, with no model calls, and
235
+ * pretending to do it incrementally would mean a partition that disagrees with itself.
236
+ * - **Summarisation is incremental and expensive.** One model call per community, and that is the cost worth
237
+ * avoiding. A community whose membership fingerprint is unchanged keeps its summary untouched.
238
+ *
239
+ * So changing one source re-clusters everything and re-summarises the handful of communities that actually
240
+ * moved. The staleness window is therefore *zero for structure* and *one rebuild for summaries*, and a
241
+ * community caught between the two is visibly stale rather than silently wrong.
242
+ */
243
+ export const createCommunityBuilder = (deps) => {
244
+ const clock = deps.clock ?? (() => new Date().toISOString());
245
+ const log = deps.log ?? (() => { });
246
+ const maxExcerpts = Math.max(1, deps.maxExcerpts ?? DEFAULT_MAX_EXCERPTS);
247
+ return {
248
+ summariserId: deps.summariser?.id ?? null,
249
+ async rebuild(context) {
250
+ // Everything, because clustering is global. Paged out rather than assumed to fit in one call.
251
+ const entities = [];
252
+ let cursor;
253
+ do {
254
+ const page = await deps.store.listEntities({
255
+ tenantId: context.tenantId,
256
+ limit: 500,
257
+ ...(cursor === undefined ? {} : { cursor }),
258
+ });
259
+ entities.push(...page.items);
260
+ cursor = page.nextCursor;
261
+ } while (cursor !== undefined);
262
+ const empty = {
263
+ communities: 0,
264
+ levels: 0,
265
+ summariesKept: 0,
266
+ summariesWritten: 0,
267
+ summariesFailed: 0,
268
+ inputTokens: 0,
269
+ outputTokens: 0,
270
+ };
271
+ if (entities.length === 0) {
272
+ await deps.store.replaceCommunities({ tenantId: context.tenantId, communities: [] });
273
+ return empty;
274
+ }
275
+ const entityIds = entities.map((entity) => entity.id).sort();
276
+ const relationships = await deps.store.neighbours({
277
+ tenantId: context.tenantId,
278
+ entityIds,
279
+ // Every edge, because clustering on a sample of the graph would produce a partition of a graph that
280
+ // does not exist. The bound is generous rather than absent so a runaway corpus fails loudly.
281
+ limit: Math.max(1000, entityIds.length * 20),
282
+ });
283
+ const chunksByEntity = new Map(entities.map((entity) => [entity.id, entity.provenance]));
284
+ const communities = detectCommunities({
285
+ entityIds,
286
+ relationships,
287
+ chunksOf: (id) => chunksByEntity.get(id) ?? [],
288
+ ...(deps.levels === undefined ? {} : { levels: deps.levels }),
289
+ });
290
+ const written = await deps.store.replaceCommunities({ tenantId: context.tenantId, communities });
291
+ const levels = new Set(communities.map((community) => community.level)).size;
292
+ if (deps.summariser === undefined) {
293
+ return { ...empty, communities: communities.length, levels, summariesKept: written.summariesKept };
294
+ }
295
+ const namesById = new Map(entities.map((entity) => [entity.id, entity.name]));
296
+ const byId = new Map(relationships.map((edge) => [edge.id, edge]));
297
+ let summariesWritten = 0;
298
+ let summariesFailed = 0;
299
+ let inputTokens = 0;
300
+ let outputTokens = 0;
301
+ const wantedLevels = deps.summariseLevels === undefined ? null : new Set(deps.summariseLevels);
302
+ for (const community of communities) {
303
+ // A level nobody queries costs nothing — see `summariseLevels`.
304
+ if (wantedLevels !== null && !wantedLevels.has(community.level))
305
+ continue;
306
+ // Only what changed. `replaceCommunities` already carried over the summaries that survived, so a
307
+ // community that still has one is one this pass must not pay for again.
308
+ const stored = await deps.store.getCommunity({ tenantId: context.tenantId, id: community.id });
309
+ if (stored?.summary !== undefined && stored.summaryFingerprint === community.fingerprint)
310
+ continue;
311
+ const excerpts = [];
312
+ if (deps.knowledge !== undefined) {
313
+ for (const chunkId of community.chunkIds.slice(0, maxExcerpts)) {
314
+ const chunk = await deps.knowledge.get({ tenantId: context.tenantId, id: chunkId });
315
+ if (chunk !== null)
316
+ excerpts.push(chunk.content);
317
+ }
318
+ }
319
+ try {
320
+ const { summary, usage } = await deps.summariser.summarise({
321
+ community,
322
+ excerpts,
323
+ entityNames: community.entityIds.map((id) => namesById.get(id) ?? id),
324
+ relationshipDescriptions: community.relationshipIds.map((id) => {
325
+ const edge = byId.get(id);
326
+ return edge === undefined
327
+ ? id
328
+ : `${namesById.get(edge.fromId) ?? edge.fromId} ${edge.type} ${namesById.get(edge.toId) ?? edge.toId}`;
329
+ }),
330
+ });
331
+ inputTokens += usage?.inputTokens ?? 0;
332
+ outputTokens += usage?.outputTokens ?? 0;
333
+ if (summary.trim() === "") {
334
+ // An empty summary is a failure wearing a success's shape: `graph-global` would reduce over it and
335
+ // silently under-report the corpus.
336
+ summariesFailed += 1;
337
+ continue;
338
+ }
339
+ await deps.store.setCommunitySummary({
340
+ tenantId: context.tenantId,
341
+ id: community.id,
342
+ summary,
343
+ fingerprint: community.fingerprint,
344
+ at: clock(),
345
+ });
346
+ summariesWritten += 1;
347
+ }
348
+ catch (error) {
349
+ /**
350
+ * AC-7. A failed summarisation leaves the community **without** a summary, never with a wrong one.
351
+ *
352
+ * Swallowed rather than propagated because the clustering succeeded and is useful on its own —
353
+ * failing the rebuild would discard a correct hierarchy over one model call. `graph-global` sees a
354
+ * community with no summary and can say so.
355
+ */
356
+ summariesFailed += 1;
357
+ log("community summarisation failed", {
358
+ communityId: community.id,
359
+ reason: error instanceof Error ? error.message : String(error),
360
+ });
361
+ }
362
+ }
363
+ if (summariesFailed > 0)
364
+ log("some communities have no summary", { failed: summariesFailed, of: communities.length });
365
+ return {
366
+ communities: communities.length,
367
+ levels,
368
+ summariesKept: written.summariesKept,
369
+ summariesWritten,
370
+ summariesFailed,
371
+ inputTokens,
372
+ outputTokens,
373
+ };
374
+ },
375
+ };
376
+ };
377
+ //# sourceMappingURL=communities.js.map
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `graph-global` — map-reduce over community summaries. REQ-064 (#270), task #274.
3
+ *
4
+ * The mode that answers *"what are the main themes in our incident reports?"* — a question that requires having
5
+ * read everything, which top-k retrieval by construction does not.
6
+ *
7
+ * ## The output shape, decided — AC-7
8
+ *
9
+ * The issue left this open, and it is the one real design question here. Three candidates:
10
+ *
11
+ * 1. **A synthesised answer** in a new shape. Most honest about what happened, and it breaks every existing
12
+ * consumer of `RetrievalOutcome` — citations, the empty-result union, the tools above.
13
+ * 2. **Synthetic chunks** whose content is the community summary. Tempting and wrong: a `KnowledgeChunk` has an
14
+ * id that resolves and a source that exists, and a fabricated one produces a citation pointing at nothing.
15
+ * The model would present a generated summary as though a document said it, which is the failure provenance
16
+ * exists to prevent.
17
+ * 3. **Real chunk hits from the communities the map phase selected.** Citations resolve, every consumer works
18
+ * unchanged, and the corpus-level reasoning is real — it happened in *choosing which communities matter*,
19
+ * which is what reading everything buys you.
20
+ *
21
+ * **Three**, and the summaries are not discarded: they come back on `GraphGlobalResult` alongside coverage, for
22
+ * a caller that wants the thematic answer rather than the passages behind it. What this mode does *not* do is
23
+ * hand the model a summary dressed as a source.
24
+ *
25
+ * ## The ceiling is the design, not a guard bolted on
26
+ *
27
+ * Cost scales with the number of communities, which scales with the corpus. A large tenant's global query could
28
+ * issue hundreds of model calls. So the ceiling is checked **before spending**, from the community count — a
29
+ * refusal that arrives after two hundred calls is not a ceiling, it is a bill with an apology.
30
+ *
31
+ * And it **refuses** rather than truncating. A partial map-reduce presented as a whole-corpus answer is a wrong
32
+ * answer with a confident shape, which is worse than no answer: "the main themes are X and Y" is false if it
33
+ * read a third of the corpus, and nothing in the sentence says so.
34
+ */
35
+ import type { TenantId } from "../core/ids.js";
36
+ import { type GraphStore, type KnowledgeChunk, type KnowledgeSourceType, type KnowledgeStore, type StoredCommunity } from "../persistence/index.js";
37
+ /**
38
+ * What the map phase says about one community.
39
+ *
40
+ * A score and the points that earned it. The score is the model's own judgement of relevance, which is the
41
+ * only thing available — there is no embedding of "what are the main themes" that ranks a summary usefully.
42
+ */
43
+ export type CommunityRelevance = {
44
+ readonly communityId: string;
45
+ /** 0–10, as the mapper reported it. Zero means the community has nothing to do with the question. */
46
+ readonly score: number;
47
+ /** What in this community bears on the question, in the mapper's words. Empty when nothing does. */
48
+ readonly points: readonly string[];
49
+ };
50
+ /**
51
+ * Scores one community summary against the question.
52
+ *
53
+ * A port, so the map phase can be a cheap model, a local one, or — for a test — a deterministic function. This
54
+ * is the call that runs once per community per query, so it is the one worth making cheap.
55
+ */
56
+ export interface CommunityMapper {
57
+ readonly id: string;
58
+ map(input: {
59
+ readonly query: string;
60
+ readonly community: StoredCommunity;
61
+ }): Promise<{
62
+ readonly relevance: CommunityRelevance;
63
+ readonly usage?: {
64
+ readonly inputTokens?: number;
65
+ readonly outputTokens?: number;
66
+ };
67
+ }>;
68
+ }
69
+ /**
70
+ * How many model calls one global query may make, by default.
71
+ *
72
+ * Forty is a corpus of a few hundred documents at a coarse level. It is deliberately a number somebody has to
73
+ * raise consciously: the failure this bounds is not a slow query, it is a tenant growing until one question
74
+ * costs more than a day of ordinary use.
75
+ */
76
+ export declare const DEFAULT_GLOBAL_CALL_CEILING = 40;
77
+ /** A companion ceiling, because a small number of enormous summaries costs as much as many small ones. */
78
+ export declare const DEFAULT_GLOBAL_TOKEN_CEILING = 120000;
79
+ /** Below this the map phase considered the community irrelevant and its chunks are not gathered. */
80
+ export declare const DEFAULT_RELEVANCE_SCORE = 1;
81
+ export type GraphGlobalResult = {
82
+ readonly hits: readonly {
83
+ readonly chunk: KnowledgeChunk;
84
+ readonly score: number;
85
+ readonly communityId: string;
86
+ }[];
87
+ /**
88
+ * The thematic answer: the summaries the map phase kept, best first, with what it found in each.
89
+ *
90
+ * Returned rather than folded into the hits, for the reason in the header — a summary is generated text and
91
+ * must never be dressed as a source.
92
+ */
93
+ readonly themes: readonly {
94
+ readonly communityId: string;
95
+ readonly summary: string;
96
+ readonly score: number;
97
+ readonly points: readonly string[];
98
+ /** True when this summary predates the community's current membership — disclosed, never hidden. */
99
+ readonly stale: boolean;
100
+ }[];
101
+ /** What was actually read, so "I read your corpus" and "I read 12 of 40 communities" are distinguishable. */
102
+ readonly coverage: {
103
+ readonly level: number;
104
+ readonly communitiesTotal: number;
105
+ readonly communitiesRead: number;
106
+ readonly communitiesRelevant: number;
107
+ /** Communities skipped because they have no summary yet — knowable, not silently absent. */
108
+ readonly communitiesUnsummarised: number;
109
+ readonly staleSummaries: number;
110
+ readonly calls: number;
111
+ readonly inputTokens: number;
112
+ readonly outputTokens: number;
113
+ readonly elapsedMs: number;
114
+ };
115
+ };
116
+ export type GraphGlobalSearchDeps = {
117
+ readonly graph: GraphStore;
118
+ readonly knowledge: KnowledgeStore;
119
+ readonly mapper: CommunityMapper;
120
+ /** Which granularity to read. Absent means choose by question breadth — see `chooseLevel`. */
121
+ readonly level?: number;
122
+ readonly callCeiling?: number;
123
+ readonly tokenCeiling?: number;
124
+ readonly minScore?: number;
125
+ /** Chunks gathered per relevant community. The reduce is over summaries; these are the passages behind them. */
126
+ readonly chunksPerCommunity?: number;
127
+ readonly now?: () => number;
128
+ };
129
+ export interface GraphGlobalSearch {
130
+ search(context: {
131
+ readonly tenantId: TenantId;
132
+ }, input: {
133
+ readonly query: string;
134
+ readonly authSubjects: readonly string[];
135
+ readonly limit: number;
136
+ readonly sourceTypes?: readonly KnowledgeSourceType[];
137
+ /** Overrides the configured level, for a caller that knows the question's breadth. */
138
+ readonly level?: number;
139
+ }): Promise<GraphGlobalResult>;
140
+ }
141
+ /**
142
+ * Which level of the hierarchy to read.
143
+ *
144
+ * The coarsest available, by default. Reading every level multiplies cost for largely repeated content — a
145
+ * level-1 community contains the level-0 ones under it, so summarising both says the same thing twice at twice
146
+ * the price. Coarse suits "what are the main themes"; a caller with a narrower question passes a finer level.
147
+ *
148
+ * Exported because the choice is worth testing on its own: picking the wrong level is the difference between
149
+ * an answer about the corpus and an answer about one team.
150
+ */
151
+ export declare const chooseLevel: (available: readonly number[], requested?: number) => number;
152
+ export declare const createGraphGlobalSearch: (deps: GraphGlobalSearchDeps) => GraphGlobalSearch;
153
+ //# sourceMappingURL=graph-global.d.ts.map