@desplega.ai/agent-swarm 1.110.0 → 1.111.0

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 (100) hide show
  1. package/README.md +1 -1
  2. package/dist/{actions-837ktebk.js → actions-a4crgvtd.js} +6 -6
  3. package/dist/{app-85h97pg2.js → app-va5mr9yw.js} +3 -3
  4. package/dist/{assistant-yhrta0c5.js → assistant-0jm4vy8r.js} +9 -9
  5. package/dist/{boot-reembed-8xmfv2pq.js → boot-reembed-0t815mmm.js} +3 -3
  6. package/dist/{boot-reembed-5sd1t9r4.js → boot-reembed-50zzk97q.js} +4 -4
  7. package/dist/{boot-scrub-logs-fdw8wbtq.js → boot-scrub-logs-r99nbmxh.js} +2 -2
  8. package/dist/{claude-managed-setup-yhbcx0bz.js → claude-managed-setup-cm05wtek.js} +1 -1
  9. package/dist/{cli-gb7dqnna.js → cli-0d404x64.js} +1 -1
  10. package/dist/{cli-5kav4x1a.js → cli-0d5xddf1.js} +7 -2
  11. package/dist/{cli-3t97r3y1.js → cli-1aqc46he.js} +5 -5
  12. package/dist/{cli-dhph6q98.js → cli-2b0ew5v8.js} +1 -1
  13. package/dist/{cli-6ttdv68b.js → cli-3cm8ar1c.js} +1 -1
  14. package/dist/{cli-2h86bsfe.js → cli-4f6vw5vy.js} +2 -2
  15. package/dist/{cli-d8vmtyyr.js → cli-4ke1amr9.js} +1 -1
  16. package/dist/{cli-3baz70yp.js → cli-6tfm5fr1.js} +1 -1
  17. package/dist/{cli-m3enfr8q.js → cli-6wscj9z0.js} +4 -4
  18. package/dist/{cli-0pzg1f8z.js → cli-8j1h9627.js} +14 -14
  19. package/dist/{cli-8ps5wsk9.js → cli-aqr96mny.js} +1 -1
  20. package/dist/{cli-w6zp4qcx.js → cli-ceewqajw.js} +37 -39
  21. package/dist/{cli-kna0r84e.js → cli-dza5039b.js} +317 -30
  22. package/dist/{cli-sjm0e1pa.js → cli-ee3fkebs.js} +2 -2
  23. package/dist/{cli-4trfcacj.js → cli-f1f82qgv.js} +5 -5
  24. package/dist/{cli-cwety9qq.js → cli-fdnpymsv.js} +2 -2
  25. package/dist/{cli-k6ssh5dk.js → cli-gchg43wt.js} +1 -1
  26. package/dist/{cli-dy01kwxk.js → cli-h3vhr1gj.js} +2 -2
  27. package/dist/{cli-8s0swp2t.js → cli-jrmjxsnk.js} +3 -3
  28. package/dist/{cli-kg9v8h1w.js → cli-kavm2cv5.js} +1 -1
  29. package/dist/{cli-zw3cmjh6.js → cli-r3y5chyg.js} +4 -4
  30. package/dist/{cli-gtt1f14h.js → cli-t8m00q3b.js} +3 -3
  31. package/dist/{cli-amncg1dq.js → cli-tk9vyqzh.js} +66 -9
  32. package/dist/{cli-g44ffxk5.js → cli-xqzvygxp.js} +1 -1
  33. package/dist/{cli-r19ttke3.js → cli-y7v6e7yr.js} +4 -4
  34. package/dist/{cli-vfyhrk9k.js → cli-z626gjqj.js} +1 -1
  35. package/dist/cli.js +11 -11
  36. package/dist/{codex-adapter-ebs12jef.js → codex-adapter-5cmy858w.js} +3 -3
  37. package/dist/{codex-login-4sywkz6v.js → codex-login-0h18cy3e.js} +1 -1
  38. package/dist/{codex-session-runner-3bx0b1hn.js → codex-session-runner-46q8mrba.js} +3 -3
  39. package/dist/{commands-3fh75nzv.js → commands-t3rdvb55.js} +2 -2
  40. package/dist/{db-kwfxkhpw.js → db-mjqc8hj6.js} +2 -2
  41. package/dist/{handlers-jj8rzetb.js → handlers-mcmg5qgc.js} +9 -9
  42. package/dist/{hook-1b45zzz1.js → hook-0p4q1tym.js} +3 -3
  43. package/dist/{http-71964f7a.js → http-gs6fk9sg.js} +232 -37
  44. package/dist/{index-zm0x6vmf.js → index-20mf9wvc.js} +8 -8
  45. package/dist/{index-c35g752k.js → index-6zpajd9e.js} +7 -11
  46. package/dist/{index-464v93y6.js → index-744yvc8x.js} +9 -9
  47. package/dist/{index-e01wmtj0.js → index-w15fk28m.js} +7 -7
  48. package/dist/{keepalive-2gxqjgrx.js → keepalive-3gvnbxaj.js} +4 -4
  49. package/dist/{lead-twm4zp8y.js → lead-yjbmhj3g.js} +20 -20
  50. package/dist/{maintenance-1htsnyz6.js → maintenance-jbvy2bfg.js} +4 -4
  51. package/dist/{onboard-1y1pdzt5.js → onboard-sexatejh.js} +2 -2
  52. package/dist/{otel-impl-pw9tnc80.js → otel-impl-sgqvcw0f.js} +1 -1
  53. package/dist/{pi-mono-adapter-ayr3akk8.js → pi-mono-adapter-kxjxpfgy.js} +2 -2
  54. package/dist/{pricing-refresh-tqvvrk7a.js → pricing-refresh-rat240yg.js} +4 -4
  55. package/dist/{seed-pricing-f23sehzq.js → seed-pricing-7yfaf123.js} +3 -3
  56. package/dist/{setup-jtw9rz9t.js → setup-vm5vcc6c.js} +2 -2
  57. package/dist/{worker-jxrmzwb9.js → worker-02y2r0r2.js} +20 -20
  58. package/openapi.json +1010 -2
  59. package/package.json +1 -1
  60. package/src/be/memory/constants.ts +7 -0
  61. package/src/be/memory/graph-expansion.ts +167 -0
  62. package/src/be/memory/link-resolver.ts +81 -17
  63. package/src/be/memory/links-store.ts +208 -0
  64. package/src/be/memory/providers/sqlite-store.ts +2 -2
  65. package/src/be/memory/raters/retrieval.ts +2 -1
  66. package/src/be/memory/types.ts +1 -1
  67. package/src/be/memory/usefulness-stats.ts +259 -0
  68. package/src/be/migrations/107_memory_usefulness_indexes.sql +12 -0
  69. package/src/be/seed-scripts/catalog/smart-recall.ts +1 -5
  70. package/src/github/mentions-aliases.test.ts +4 -3
  71. package/src/http/codex-oauth-keep-warm.ts +107 -0
  72. package/src/http/index.ts +2 -0
  73. package/src/http/memory.ts +73 -6
  74. package/src/providers/codex-adapter.ts +66 -2
  75. package/src/providers/codex-oauth/auth-json.ts +18 -9
  76. package/src/providers/codex-oauth/flow.ts +1 -1
  77. package/src/providers/codex-oauth/storage.ts +88 -6
  78. package/src/providers/codex-oauth/types.ts +1 -1
  79. package/src/tests/codex-adapter-pool-auth-revalidate.test.ts +113 -11
  80. package/src/tests/codex-adapter.test.ts +27 -0
  81. package/src/tests/codex-oauth-keep-warm.test.ts +268 -0
  82. package/src/tests/codex-oauth-refresh-lock.test.ts +4 -4
  83. package/src/tests/codex-oauth-storage.test.ts +149 -6
  84. package/src/tests/codex-oauth.test.ts +12 -0
  85. package/src/tests/create-page-tool.test.ts +7 -0
  86. package/src/tests/entrypoint-codex-oauth-seed.test.ts +124 -0
  87. package/src/tests/jira-webhook-lifecycle.test.ts +7 -0
  88. package/src/tests/memory-edit.test.ts +95 -0
  89. package/src/tests/memory-get-tool.test.ts +138 -0
  90. package/src/tests/memory-graph-expansion.test.ts +421 -0
  91. package/src/tests/memory-http-recall-gating.test.ts +13 -0
  92. package/src/tests/memory-link-resolver.test.ts +228 -2
  93. package/src/tests/memory-usefulness-endpoint.test.ts +392 -0
  94. package/src/tests/script-workflows-runtime-e2e.test.ts +1 -0
  95. package/src/tests/slack-upload-file.test.ts +135 -0
  96. package/src/tools/inject-learning.ts +1 -0
  97. package/src/tools/memory-edit.ts +3 -2
  98. package/src/tools/memory-get.ts +66 -1
  99. package/src/tools/memory-search.ts +13 -3
  100. package/src/tools/slack-upload-file.ts +11 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.110.0",
3
+ "version": "1.111.0",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -65,6 +65,13 @@ export function isHybridSearchEnabled(): boolean {
65
65
  return val === "1" || val.toLowerCase() === "true";
66
66
  }
67
67
 
68
+ // Feature flag: expand search candidates with 1-hop memory_link graph neighbors
69
+ // (DES-639a). Off by default; set MEMORY_GRAPH_EXPANSION=1 to enable.
70
+ export function isGraphExpansionEnabled(): boolean {
71
+ const val = process.env.MEMORY_GRAPH_EXPANSION ?? "0";
72
+ return val === "1" || val.toLowerCase() === "true";
73
+ }
74
+
68
75
  // Embedding defaults
69
76
  export const EMBEDDING_DIMENSIONS = numEnv("EMBEDDING_DIMENSIONS", 512);
70
77
  export const DEFAULT_EMBEDDING_DIMENSIONS = EMBEDDING_DIMENSIONS;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Graph candidate expansion (DES-639a) — the first READER of `memory_link`.
3
+ *
4
+ * Expands a search candidate set with 1-hop outgoing `memory_link` neighbors
5
+ * (`targetKind='memory'` rows whose targetId resolves to a real, non-expired
6
+ * memory), so a memory linked from a strong hit can surface in results it
7
+ * would never reach by similarity alone.
8
+ *
9
+ * Runs at the seam between `store.search()` and `rerank()` at both call sites
10
+ * (`POST /api/memory/search` and the `memory-search` MCP tool), so:
11
+ * - all four retrieval arms (vec/fts/hybrid/fallback) get expansion,
12
+ * - `rerank()` stays pure (no DB access),
13
+ * - `recordRetrievals` downstream picks up `retrievalSource: "graph"` for free.
14
+ *
15
+ * Flag-gated: `MEMORY_GRAPH_EXPANSION=1|true` (default off — same rollout
16
+ * pattern as MEMORY_HYBRID_SEARCH). With the flag off the input array is
17
+ * returned unchanged (same reference), keeping results byte-identical.
18
+ *
19
+ * Plan: thoughts/taras/plans/2026-07-02-memory-retrieval-v2-graph-and-measurement.md Phase 4
20
+ */
21
+ import { getDb } from "@/be/db";
22
+ import type { AgentMemorySource } from "@/types";
23
+ import { isGraphExpansionEnabled } from "./constants";
24
+ import { type AgentMemoryRow, rowToCandidate } from "./providers/sqlite-store";
25
+ import { computeScore } from "./reranker";
26
+ import type { MemoryCandidate } from "./types";
27
+
28
+ export interface GraphExpansionOptions {
29
+ /** Max NEW candidates added (replacing an existing candidate doesn't count). Default 5. */
30
+ cap?: number;
31
+ /** Per-hop score damping: neighborSim = parentSim × link.strength × damping. Default 0.7. */
32
+ damping?: number;
33
+ /** Scope the search ran with — neighbor visibility mirrors the search ACL. Default "all". */
34
+ scope?: "agent" | "swarm" | "all";
35
+ /** Source filter the search ran with — expansion must not add off-filter rows. */
36
+ source?: AgentMemorySource;
37
+ isLead?: boolean;
38
+ }
39
+
40
+ type NeighborRow = AgentMemoryRow & { fromMemoryId: string; linkStrength: number | null };
41
+
42
+ /**
43
+ * Mirrors `SqliteMemoryStore.addScopeConditions` (private) for the `m` alias:
44
+ * non-lead callers see own-agent rows + swarm-scoped rows; leads see all.
45
+ */
46
+ function addNeighborScopeConditions(
47
+ conditions: string[],
48
+ params: (string | number)[],
49
+ agentId: string,
50
+ scope: "agent" | "swarm" | "all",
51
+ isLead: boolean,
52
+ ): void {
53
+ if (!isLead) {
54
+ if (scope === "agent") {
55
+ conditions.push("m.agentId = ? AND m.scope = 'agent'");
56
+ params.push(agentId);
57
+ } else if (scope === "swarm") {
58
+ conditions.push("m.scope = 'swarm'");
59
+ } else {
60
+ conditions.push("(m.agentId = ? OR m.scope = 'swarm')");
61
+ params.push(agentId);
62
+ }
63
+ } else {
64
+ if (scope === "agent") {
65
+ conditions.push("m.scope = 'agent'");
66
+ } else if (scope === "swarm") {
67
+ conditions.push("m.scope = 'swarm'");
68
+ }
69
+ }
70
+ }
71
+
72
+ export function expandCandidatesWithGraph(
73
+ candidates: MemoryCandidate[],
74
+ agentId: string,
75
+ options: GraphExpansionOptions = {},
76
+ ): MemoryCandidate[] {
77
+ if (!isGraphExpansionEnabled()) return candidates;
78
+ const { cap = 5, damping = 0.7, scope = "all", source, isLead = false } = options;
79
+ if (candidates.length === 0 || cap <= 0) return candidates;
80
+
81
+ const parentById = new Map(candidates.map((c) => [c.id, c]));
82
+ const parentIds = [...parentById.keys()];
83
+
84
+ // Outgoing memory→memory links whose targetId JOINs to a live agent_memory
85
+ // row. Unresolved wikilinks keep the raw `[[Name]]` text as targetId
86
+ // (link-resolver.ts) and therefore never match an id — the JOIN skips them.
87
+ const conditions: string[] = [
88
+ `ml.from_memory_id IN (${parentIds.map(() => "?").join(", ")})`,
89
+ "ml.targetKind = 'memory'",
90
+ "(m.expiresAt IS NULL OR m.expiresAt > datetime('now'))",
91
+ ];
92
+ const params: (string | number)[] = [...parentIds];
93
+ addNeighborScopeConditions(conditions, params, agentId, scope, isLead);
94
+ if (source) {
95
+ conditions.push("m.source = ?");
96
+ params.push(source);
97
+ }
98
+
99
+ let rows: NeighborRow[];
100
+ try {
101
+ rows = getDb()
102
+ .prepare<NeighborRow, (string | number)[]>(
103
+ `SELECT m.*, ml.from_memory_id AS fromMemoryId, ml.strength AS linkStrength
104
+ FROM memory_link ml
105
+ JOIN agent_memory m ON m.id = ml.targetId
106
+ WHERE ${conditions.join(" AND ")}`,
107
+ )
108
+ .all(...params);
109
+ } catch (err) {
110
+ // Best-effort: a graph-expansion failure must never poison search.
111
+ console.warn("[memory-graph] expansion query failed:", (err as Error).message);
112
+ return candidates;
113
+ }
114
+ if (rows.length === 0) return candidates;
115
+
116
+ // Best derived score per neighbor (a neighbor may be linked from several parents).
117
+ const neighbors = new Map<string, MemoryCandidate>();
118
+ for (const row of rows) {
119
+ const parent = parentById.get(row.fromMemoryId);
120
+ if (!parent) continue;
121
+ const strength = typeof row.linkStrength === "number" ? row.linkStrength : 1.0;
122
+ // Derive from the parent's RAW (pre-decay) similarity — fts/hybrid arms
123
+ // ship `similarity` with the parent's recency decay already applied, and
124
+ // rerank() will apply the NEIGHBOR's own decay to this candidate. Using
125
+ // the decayed value would stack two decay factors on one score.
126
+ const parentBase = parent.rawSimilarity ?? parent.similarity;
127
+ const similarity = parentBase * strength * damping;
128
+ const existing = neighbors.get(row.id);
129
+ if (existing && existing.similarity >= similarity) continue;
130
+ neighbors.set(row.id, {
131
+ ...rowToCandidate(row, similarity),
132
+ retrievalSource: "graph",
133
+ // The neighbor's own decay is applied exactly once by rerank().
134
+ recencyDecayApplied: false,
135
+ });
136
+ }
137
+ if (neighbors.size === 0) return candidates;
138
+
139
+ const result = [...candidates];
140
+ const indexById = new Map(result.map((c, i) => [c.id, i] as const));
141
+ let added = 0;
142
+ // Rank and compare via the reranker's composite score (recency decay,
143
+ // source quality, access, usefulness), NOT the raw similarity: cap selection
144
+ // by raw score would let stale low-value links crowd out neighbors the
145
+ // reranker would place higher, and organic fts/hybrid duplicates carry an
146
+ // already-decayed similarity that a raw comparison would mis-rank against
147
+ // the graph entry's deliberately pre-decay score.
148
+ const now = new Date();
149
+ const ranked = [...neighbors.values()].sort(
150
+ (a, b) => computeScore(b, now) - computeScore(a, now),
151
+ );
152
+ for (const neighbor of ranked) {
153
+ const existingIndex = indexById.get(neighbor.id);
154
+ if (existingIndex !== undefined) {
155
+ // Dedupe against organic candidates: keep whichever entry the reranker
156
+ // will score higher (same memory, so all non-similarity factors match).
157
+ if (computeScore(neighbor, now) > computeScore(result[existingIndex]!, now)) {
158
+ result[existingIndex] = neighbor;
159
+ }
160
+ continue;
161
+ }
162
+ if (added >= cap) continue;
163
+ result.push(neighbor);
164
+ added += 1;
165
+ }
166
+ return result;
167
+ }
@@ -183,6 +183,30 @@ export function resolveWikilinksToMemoryIds(
183
183
  });
184
184
  }
185
185
 
186
+ const INSERT_LINK_SQL = `INSERT OR IGNORE INTO memory_link
187
+ (id, from_memory_id, linkType, targetKind, targetId, strength, resolver, sourceText, metadata, createdAt, updatedAt)
188
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
189
+
190
+ function insertLinkArgs(
191
+ memoryId: string,
192
+ link: ResolvedLink,
193
+ now: string,
194
+ ): [string, string, string, string, string, number, string, string, string | null, string, string] {
195
+ return [
196
+ crypto.randomUUID(),
197
+ memoryId,
198
+ link.linkType,
199
+ link.targetKind,
200
+ link.targetId,
201
+ link.strength,
202
+ link.resolver,
203
+ link.sourceText,
204
+ link.metadata ? JSON.stringify(link.metadata) : null,
205
+ now,
206
+ now,
207
+ ];
208
+ }
209
+
186
210
  export function storeLinks(memoryId: string, agentId: string, content: string): void {
187
211
  const links = resolveLinks(content);
188
212
  if (links.length === 0) return;
@@ -190,27 +214,67 @@ export function storeLinks(memoryId: string, agentId: string, content: string):
190
214
  const resolved = resolveWikilinksToMemoryIds(agentId, links);
191
215
  const db = getDb();
192
216
  const now = new Date().toISOString();
193
- const insert = db.prepare(
194
- `INSERT OR IGNORE INTO memory_link
195
- (id, from_memory_id, linkType, targetKind, targetId, strength, resolver, sourceText, metadata, createdAt, updatedAt)
196
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
217
+ const insert = db.prepare(INSERT_LINK_SQL);
218
+
219
+ db.transaction(() => {
220
+ for (const link of resolved) {
221
+ insert.run(...insertLinkArgs(memoryId, link, now));
222
+ }
223
+ })();
224
+ }
225
+
226
+ /** The `memory_link` UNIQUE-constraint identity — the natural diff key for pruning. */
227
+ function linkIdentity(link: {
228
+ linkType: string;
229
+ targetKind: string;
230
+ targetId: string;
231
+ sourceText: string | null;
232
+ }): string {
233
+ return JSON.stringify([link.linkType, link.targetKind, link.targetId, link.sourceText ?? null]);
234
+ }
235
+
236
+ /**
237
+ * Re-derive content links on the EDIT/RE-INDEX paths (DES-639b).
238
+ *
239
+ * Unlike the additive `storeLinks`, this prunes: in one transaction it
240
+ * deletes content-derived rows (`linkType != 'sequel'`) whose UNIQUE identity
241
+ * (linkType, targetKind, targetId, sourceText) is absent from the new
242
+ * content's resolved set, then INSERT OR IGNOREs the new set. `sequel` links
243
+ * are preserved — they are created by `storeSequelLink` (resolver
244
+ * 'sequel-auto') and are not derivable from content. Fresh-store paths keep
245
+ * plain `storeLinks`.
246
+ */
247
+ export function refreshLinks(memoryId: string, agentId: string, content: string): void {
248
+ const resolved = resolveWikilinksToMemoryIds(agentId, resolveLinks(content));
249
+ const nextIdentities = new Set(resolved.map((link) => linkIdentity(link)));
250
+
251
+ const db = getDb();
252
+ const now = new Date().toISOString();
253
+ const selectExisting = db.prepare<
254
+ {
255
+ id: string;
256
+ linkType: string;
257
+ targetKind: string;
258
+ targetId: string;
259
+ sourceText: string | null;
260
+ },
261
+ [string]
262
+ >(
263
+ `SELECT id, linkType, targetKind, targetId, sourceText
264
+ FROM memory_link
265
+ WHERE from_memory_id = ? AND linkType != 'sequel'`,
197
266
  );
267
+ const deleteById = db.prepare("DELETE FROM memory_link WHERE id = ?");
268
+ const insert = db.prepare(INSERT_LINK_SQL);
198
269
 
199
270
  db.transaction(() => {
271
+ for (const row of selectExisting.all(memoryId)) {
272
+ if (!nextIdentities.has(linkIdentity(row))) {
273
+ deleteById.run(row.id);
274
+ }
275
+ }
200
276
  for (const link of resolved) {
201
- insert.run(
202
- crypto.randomUUID(),
203
- memoryId,
204
- link.linkType,
205
- link.targetKind,
206
- link.targetId,
207
- link.strength,
208
- link.resolver,
209
- link.sourceText,
210
- link.metadata ? JSON.stringify(link.metadata) : null,
211
- now,
212
- now,
213
- );
277
+ insert.run(...insertLinkArgs(memoryId, link, now));
214
278
  }
215
279
  })();
216
280
  }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Read-side query helpers for the `memory_link` table (DES-639b).
3
+ *
4
+ * The write path lives in `src/be/memory/link-resolver.ts` (`storeLinks` /
5
+ * `refreshLinks` / `storeSequelLink`). This module surfaces traversal reads
6
+ * to the memory-get surfaces (`GET /api/memory/{id}` and the `memory-get`
7
+ * MCP tool): outgoing links plus inbound `targetKind='memory'` rows as a
8
+ * `backlinks` array (same table, cheap).
9
+ *
10
+ * Visibility mirrors the search ACL (see `graph-expansion.ts`): non-lead
11
+ * viewers see own-agent + swarm-scoped linked memories; leads see all.
12
+ * `memory_link.targetId` has no FK — dangling rows (target deleted, or a
13
+ * wikilink that never resolved past its raw name text) come back with
14
+ * `resolved: false` and no target metadata, indistinguishable from targets
15
+ * the viewer may not see, so nothing leaks. Server-side only.
16
+ */
17
+ import { getDb } from "@/be/db";
18
+ import type { LinkType, TargetKind } from "./link-resolver";
19
+
20
+ /** Minimal metadata about a linked/backlinking memory — enough to memory-get it. */
21
+ export interface LinkedMemoryRef {
22
+ id: string;
23
+ name: string;
24
+ scope: string;
25
+ }
26
+
27
+ export interface MemoryLinkView {
28
+ id: string;
29
+ linkType: LinkType;
30
+ targetKind: TargetKind;
31
+ targetId: string;
32
+ strength: number;
33
+ resolver: string;
34
+ sourceText: string | null;
35
+ createdAt: string;
36
+ /**
37
+ * For `targetKind='memory'`: true when targetId points at a live memory the
38
+ * viewer may see. Non-memory kinds (pr, agent-fs-file, …) are always true —
39
+ * their targetId is already the resolved external identity.
40
+ */
41
+ resolved: boolean;
42
+ /** Present only when `resolved` and `targetKind='memory'`. */
43
+ target?: LinkedMemoryRef;
44
+ }
45
+
46
+ export interface MemoryBacklinkView {
47
+ id: string;
48
+ linkType: LinkType;
49
+ strength: number;
50
+ sourceText: string | null;
51
+ createdAt: string;
52
+ /** The memory whose content links here. Always visible to the viewer (ACL-filtered). */
53
+ from: LinkedMemoryRef;
54
+ }
55
+
56
+ export interface MemoryLinksResult {
57
+ links: MemoryLinkView[];
58
+ backlinks: MemoryBacklinkView[];
59
+ }
60
+
61
+ export interface GetLinksOptions {
62
+ /** The requesting agent; undefined = anonymous (swarm-scoped visibility only). */
63
+ viewerAgentId?: string;
64
+ /** Leads see all linked-memory metadata regardless of scope. Default false. */
65
+ isLead?: boolean;
66
+ }
67
+
68
+ type OutgoingRow = {
69
+ id: string;
70
+ linkType: LinkType;
71
+ targetKind: TargetKind;
72
+ targetId: string;
73
+ strength: number;
74
+ resolver: string;
75
+ sourceText: string | null;
76
+ createdAt: string;
77
+ targetMemoryId: string | null;
78
+ targetName: string | null;
79
+ targetScope: string | null;
80
+ targetAgentId: string | null;
81
+ };
82
+
83
+ type BacklinkRow = {
84
+ id: string;
85
+ linkType: LinkType;
86
+ strength: number;
87
+ sourceText: string | null;
88
+ createdAt: string;
89
+ fromId: string;
90
+ fromName: string;
91
+ fromScope: string;
92
+ };
93
+
94
+ /** Hidden-target redaction: `[[Name]]` sourceText → `Name` (the unresolved-row form); anything else → "". */
95
+ function redactedTargetId(sourceText: string | null): string {
96
+ const wikilink = /^\[\[(.+)\]\]$/.exec(sourceText ?? "");
97
+ return wikilink?.[1] ?? "";
98
+ }
99
+
100
+ export function getLinksForMemory(
101
+ memoryId: string,
102
+ options: GetLinksOptions = {},
103
+ ): MemoryLinksResult {
104
+ const { viewerAgentId, isLead = false } = options;
105
+ const db = getDb();
106
+
107
+ // Outgoing links. The LEFT JOIN resolves memory-kind targets to a live
108
+ // agent_memory row; dangling/unresolved/expired targets join to NULL.
109
+ const outgoing = db
110
+ .prepare<OutgoingRow, [string]>(
111
+ `SELECT ml.id, ml.linkType, ml.targetKind, ml.targetId, ml.strength,
112
+ ml.resolver, ml.sourceText, ml.createdAt,
113
+ m.id AS targetMemoryId, m.name AS targetName,
114
+ m.scope AS targetScope, m.agentId AS targetAgentId
115
+ FROM memory_link ml
116
+ LEFT JOIN agent_memory m
117
+ ON ml.targetKind = 'memory'
118
+ AND m.id = ml.targetId
119
+ AND (m.expiresAt IS NULL OR m.expiresAt > datetime('now'))
120
+ WHERE ml.from_memory_id = ?
121
+ ORDER BY ml.createdAt ASC, ml.id ASC`,
122
+ )
123
+ .all(memoryId);
124
+
125
+ const links: MemoryLinkView[] = outgoing.map((row) => {
126
+ if (row.targetKind !== "memory") {
127
+ return {
128
+ id: row.id,
129
+ linkType: row.linkType,
130
+ targetKind: row.targetKind,
131
+ targetId: row.targetId,
132
+ strength: row.strength,
133
+ resolver: row.resolver,
134
+ sourceText: row.sourceText,
135
+ createdAt: row.createdAt,
136
+ resolved: true,
137
+ };
138
+ }
139
+ const live = row.targetMemoryId !== null;
140
+ const visible =
141
+ live &&
142
+ (isLead || row.targetScope === "swarm" || (row.targetAgentId ?? "") === viewerAgentId);
143
+ // Redact targetId for every memory-kind row the viewer can't see: exposing
144
+ // a stored UUID would leak private memory ids (live-but-hidden AND
145
+ // expired/deleted targets, which store the once-resolved UUID) and let a
146
+ // viewer tell those apart from a genuinely unresolved wikilink. Redact to
147
+ // the wikilink NAME (derived from sourceText, verbatim content of the
148
+ // from-memory the viewer already reads) — the exact form an unresolved row
149
+ // stores in targetId, so all non-visible variants look identical.
150
+ const base: MemoryLinkView = {
151
+ id: row.id,
152
+ linkType: row.linkType,
153
+ targetKind: row.targetKind,
154
+ targetId: visible ? row.targetId : redactedTargetId(row.sourceText),
155
+ strength: row.strength,
156
+ resolver: row.resolver,
157
+ sourceText: row.sourceText,
158
+ createdAt: row.createdAt,
159
+ resolved: false,
160
+ };
161
+ if (!visible) return base;
162
+ return {
163
+ ...base,
164
+ resolved: true,
165
+ target: {
166
+ id: row.targetMemoryId as string,
167
+ name: row.targetName ?? "",
168
+ scope: row.targetScope ?? "",
169
+ },
170
+ };
171
+ });
172
+
173
+ // Inbound links (backlinks): rows in the same table pointing at this memory.
174
+ // INNER JOIN drops rows whose source memory no longer exists; the scope
175
+ // condition keeps cross-agent agent-scoped sources invisible to non-leads.
176
+ const conditions = [
177
+ "ml.targetKind = 'memory'",
178
+ "ml.targetId = ?",
179
+ "(m.expiresAt IS NULL OR m.expiresAt > datetime('now'))",
180
+ ];
181
+ const params: (string | null)[] = [memoryId];
182
+ if (!isLead) {
183
+ conditions.push("(m.agentId = ? OR m.scope = 'swarm')");
184
+ params.push(viewerAgentId ?? null);
185
+ }
186
+
187
+ const inbound = db
188
+ .prepare<BacklinkRow, (string | null)[]>(
189
+ `SELECT ml.id, ml.linkType, ml.strength, ml.sourceText, ml.createdAt,
190
+ m.id AS fromId, m.name AS fromName, m.scope AS fromScope
191
+ FROM memory_link ml
192
+ JOIN agent_memory m ON m.id = ml.from_memory_id
193
+ WHERE ${conditions.join(" AND ")}
194
+ ORDER BY ml.createdAt ASC, ml.id ASC`,
195
+ )
196
+ .all(...params);
197
+
198
+ const backlinks: MemoryBacklinkView[] = inbound.map((row) => ({
199
+ id: row.id,
200
+ linkType: row.linkType,
201
+ strength: row.strength,
202
+ sourceText: row.sourceText,
203
+ createdAt: row.createdAt,
204
+ from: { id: row.fromId, name: row.fromName, scope: row.fromScope },
205
+ }));
206
+
207
+ return { links, backlinks };
208
+ }
@@ -26,7 +26,7 @@ import type {
26
26
 
27
27
  const VECTOR_BYTES = EMBEDDING_DIMENSIONS * Float32Array.BYTES_PER_ELEMENT;
28
28
 
29
- type AgentMemoryRow = {
29
+ export type AgentMemoryRow = {
30
30
  id: string;
31
31
  agentId: string | null;
32
32
  scope: string;
@@ -79,7 +79,7 @@ function rowToAgentMemory(row: AgentMemoryRow): AgentMemory {
79
79
  };
80
80
  }
81
81
 
82
- function rowToCandidate(row: AgentMemoryRow, similarity: number): MemoryCandidate {
82
+ export function rowToCandidate(row: AgentMemoryRow, similarity: number): MemoryCandidate {
83
83
  return {
84
84
  ...rowToAgentMemory(row),
85
85
  similarity,
@@ -1,5 +1,6 @@
1
1
  import { ensure } from "@desplega.ai/business-use";
2
2
  import { getDb } from "@/be/db";
3
+ import type { MemoryRetrievalSource } from "@/be/memory/types";
3
4
 
4
5
  /**
5
6
  * Retrieval-bridge helper — appends `memory_retrieval` audit rows so
@@ -22,7 +23,7 @@ import { getDb } from "@/be/db";
22
23
  export type RetrievalRecord = {
23
24
  memoryId: string;
24
25
  similarity: number;
25
- retrievalSource?: "vec" | "fts" | "hybrid" | "fallback";
26
+ retrievalSource?: MemoryRetrievalSource;
26
27
  };
27
28
 
28
29
  export type RetrievalExtras = {
@@ -76,7 +76,7 @@ export interface MemoryCandidate extends AgentMemory {
76
76
  beta: number;
77
77
  }
78
78
 
79
- export type MemoryRetrievalSource = "vec" | "fts" | "hybrid" | "fallback";
79
+ export type MemoryRetrievalSource = "vec" | "fts" | "hybrid" | "fallback" | "graph";
80
80
 
81
81
  export interface MemorySearchOptions {
82
82
  scope?: "agent" | "swarm" | "all";