@desplega.ai/agent-swarm 1.110.0 → 1.112.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.
- package/README.md +1 -1
- package/dist/{actions-837ktebk.js → actions-eckb4g8r.js} +6 -6
- package/dist/{app-85h97pg2.js → app-b05sgm02.js} +3 -3
- package/dist/{assistant-yhrta0c5.js → assistant-7255sbyw.js} +9 -9
- package/dist/{boot-reembed-5sd1t9r4.js → boot-reembed-y1bt3ttn.js} +4 -4
- package/dist/{boot-reembed-8xmfv2pq.js → boot-reembed-yv7awg4y.js} +3 -3
- package/dist/{boot-scrub-logs-fdw8wbtq.js → boot-scrub-logs-ddyaeqar.js} +2 -2
- package/dist/{claude-managed-setup-yhbcx0bz.js → claude-managed-setup-cm05wtek.js} +1 -1
- package/dist/{cli-8ps5wsk9.js → cli-0c3n0eba.js} +1 -1
- package/dist/{cli-kna0r84e.js → cli-15598a4k.js} +599 -51
- package/dist/{cli-8s0swp2t.js → cli-30d2ct3k.js} +3 -3
- package/dist/{cli-2h86bsfe.js → cli-3r5y6ayj.js} +2 -2
- package/dist/{cli-6ttdv68b.js → cli-52tbq17n.js} +1 -1
- package/dist/{cli-k6ssh5dk.js → cli-56s4ehzw.js} +1 -1
- package/dist/{cli-m3enfr8q.js → cli-6wscj9z0.js} +4 -4
- package/dist/{cli-5kav4x1a.js → cli-709t753c.js} +7 -2
- package/dist/{cli-g44ffxk5.js → cli-76d1vtvq.js} +11 -2
- package/dist/{cli-gb7dqnna.js → cli-7xkwgkrs.js} +1 -1
- package/dist/{cli-kg9v8h1w.js → cli-8f0dwca2.js} +1 -1
- package/dist/{cli-zw3cmjh6.js → cli-94vzr3nq.js} +4 -4
- package/dist/{cli-d8vmtyyr.js → cli-951w7c79.js} +1 -1
- package/dist/{cli-dhph6q98.js → cli-9p0qhead.js} +1 -1
- package/dist/{cli-vfyhrk9k.js → cli-a5e8n5hg.js} +1 -1
- package/dist/{cli-w6zp4qcx.js → cli-ceewqajw.js} +37 -39
- package/dist/{cli-r19ttke3.js → cli-h9jx5mag.js} +4 -4
- package/dist/{cli-4trfcacj.js → cli-hm27prrc.js} +5 -5
- package/dist/{cli-gtt1f14h.js → cli-pt7c7qxz.js} +3 -3
- package/dist/{cli-sjm0e1pa.js → cli-r1btc895.js} +2 -2
- package/dist/{cli-3baz70yp.js → cli-rp8pca98.js} +1 -1
- package/dist/{cli-dy01kwxk.js → cli-s9pj3c7z.js} +2 -2
- package/dist/{cli-0pzg1f8z.js → cli-tch0ypp6.js} +71 -24
- package/dist/{cli-3t97r3y1.js → cli-tg0f2wmz.js} +5 -5
- package/dist/{cli-amncg1dq.js → cli-tk9vyqzh.js} +66 -9
- package/dist/{cli-cwety9qq.js → cli-tp7e60s3.js} +4 -2
- package/dist/cli.js +11 -11
- package/dist/{codex-adapter-ebs12jef.js → codex-adapter-5cmy858w.js} +3 -3
- package/dist/{codex-login-4sywkz6v.js → codex-login-0h18cy3e.js} +1 -1
- package/dist/{codex-session-runner-3bx0b1hn.js → codex-session-runner-46q8mrba.js} +3 -3
- package/dist/{commands-3fh75nzv.js → commands-14g7taf4.js} +2 -2
- package/dist/{db-kwfxkhpw.js → db-3tge4jrn.js} +2 -2
- package/dist/{handlers-jj8rzetb.js → handlers-sy64egg6.js} +9 -9
- package/dist/{hook-1b45zzz1.js → hook-rnkzw8vp.js} +3 -3
- package/dist/{http-71964f7a.js → http-xx9xptcs.js} +234 -37
- package/dist/{index-e01wmtj0.js → index-3wr6v2mm.js} +7 -7
- package/dist/{index-464v93y6.js → index-brma88d4.js} +9 -9
- package/dist/{index-c35g752k.js → index-ha8e9xn2.js} +7 -11
- package/dist/{index-zm0x6vmf.js → index-zfx5p959.js} +8 -8
- package/dist/{keepalive-2gxqjgrx.js → keepalive-rbz05zad.js} +4 -4
- package/dist/{lead-twm4zp8y.js → lead-s790v7r0.js} +20 -20
- package/dist/{maintenance-1htsnyz6.js → maintenance-420rmadb.js} +4 -4
- package/dist/{onboard-1y1pdzt5.js → onboard-jgqamqnw.js} +2 -2
- package/dist/{otel-impl-pw9tnc80.js → otel-impl-ch3n3783.js} +1 -1
- package/dist/{pi-mono-adapter-ayr3akk8.js → pi-mono-adapter-kxjxpfgy.js} +2 -2
- package/dist/{pricing-refresh-tqvvrk7a.js → pricing-refresh-rmtrby6h.js} +4 -4
- package/dist/{seed-pricing-f23sehzq.js → seed-pricing-1szjzfj1.js} +3 -3
- package/dist/{setup-jtw9rz9t.js → setup-8jfzkyzv.js} +2 -2
- package/dist/{worker-jxrmzwb9.js → worker-40vq0pm8.js} +20 -20
- package/openapi.json +1010 -2
- package/package.json +1 -1
- package/src/be/memory/constants.ts +7 -0
- package/src/be/memory/graph-expansion.ts +167 -0
- package/src/be/memory/link-resolver.ts +81 -17
- package/src/be/memory/links-store.ts +208 -0
- package/src/be/memory/providers/sqlite-store.ts +2 -2
- package/src/be/memory/raters/retrieval.ts +2 -1
- package/src/be/memory/types.ts +1 -1
- package/src/be/memory/usefulness-stats.ts +259 -0
- package/src/be/migrations/107_memory_usefulness_indexes.sql +12 -0
- package/src/be/scripts/typecheck.ts +2 -0
- package/src/be/seed-scripts/catalog/smart-recall.ts +1 -5
- package/src/commands/runner.ts +154 -8
- package/src/github/handlers.ts +128 -25
- package/src/github/mentions-aliases.test.ts +4 -3
- package/src/http/codex-oauth-keep-warm.ts +107 -0
- package/src/http/index.ts +2 -0
- package/src/http/memory.ts +73 -6
- package/src/providers/codex-adapter.ts +66 -2
- package/src/providers/codex-oauth/auth-json.ts +18 -9
- package/src/providers/codex-oauth/flow.ts +1 -1
- package/src/providers/codex-oauth/storage.ts +88 -6
- package/src/providers/codex-oauth/types.ts +1 -1
- package/src/scripts-runtime/sdk-allowlist.ts +2 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -0
- package/src/server.ts +4 -0
- package/src/slack/message-text.ts +25 -0
- package/src/tests/codex-adapter-pool-auth-revalidate.test.ts +113 -11
- package/src/tests/codex-adapter.test.ts +27 -0
- package/src/tests/codex-oauth-keep-warm.test.ts +268 -0
- package/src/tests/codex-oauth-refresh-lock.test.ts +4 -4
- package/src/tests/codex-oauth-storage.test.ts +149 -6
- package/src/tests/codex-oauth.test.ts +12 -0
- package/src/tests/create-page-tool.test.ts +7 -0
- package/src/tests/entrypoint-codex-oauth-seed.test.ts +124 -0
- package/src/tests/github-event-filter.test.ts +3 -2
- package/src/tests/github-handlers-inline-comments.test.ts +114 -24
- package/src/tests/jira-webhook-lifecycle.test.ts +7 -0
- package/src/tests/memory-edit.test.ts +95 -0
- package/src/tests/memory-get-tool.test.ts +138 -0
- package/src/tests/memory-graph-expansion.test.ts +421 -0
- package/src/tests/memory-http-recall-gating.test.ts +13 -0
- package/src/tests/memory-link-resolver.test.ts +228 -2
- package/src/tests/memory-usefulness-endpoint.test.ts +392 -0
- package/src/tests/runner-repo-autostash.test.ts +168 -1
- package/src/tests/script-workflows-runtime-e2e.test.ts +1 -0
- package/src/tests/slack-delete.test.ts +157 -0
- package/src/tests/slack-message-text.test.ts +37 -1
- package/src/tests/slack-update.test.ts +167 -0
- package/src/tests/slack-upload-file.test.ts +135 -0
- package/src/tests/tool-annotations.test.ts +3 -0
- package/src/tools/inject-learning.ts +1 -0
- package/src/tools/memory-edit.ts +3 -2
- package/src/tools/memory-get.ts +66 -1
- package/src/tools/memory-search.ts +13 -3
- package/src/tools/slack-delete.ts +102 -0
- package/src/tools/slack-reply.ts +11 -3
- package/src/tools/slack-start-thread.ts +2 -0
- package/src/tools/slack-update.ts +127 -0
- package/src/tools/slack-upload-file.ts +11 -1
- package/src/tools/tool-config.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desplega.ai/agent-swarm",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.112.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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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?:
|
|
26
|
+
retrievalSource?: MemoryRetrievalSource;
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
export type RetrievalExtras = {
|
package/src/be/memory/types.ts
CHANGED
|
@@ -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";
|