@desplega.ai/agent-swarm 1.98.0 → 1.99.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 (43) hide show
  1. package/README.md +1 -0
  2. package/openapi.json +20 -1
  3. package/package.json +5 -5
  4. package/src/be/memory/link-resolver.ts +226 -0
  5. package/src/be/memory/providers/sqlite-store.ts +4 -2
  6. package/src/be/memory/raters/retrieval.ts +15 -4
  7. package/src/be/memory/raters/store.ts +4 -2
  8. package/src/be/memory/types.ts +1 -0
  9. package/src/be/migrations/096_memory_graph_phase1.sql +50 -0
  10. package/src/be/modelsdev-cache.ts +5 -0
  11. package/src/be/pricing-refresh.ts +189 -0
  12. package/src/be/scripts/typecheck.ts +3 -2
  13. package/src/be/seed-pricing.ts +5 -3
  14. package/src/commands/profile-sync.ts +83 -17
  15. package/src/commands/runner.ts +35 -3
  16. package/src/e2b/dispatch.ts +5 -0
  17. package/src/hooks/hook.ts +21 -5
  18. package/src/http/index.ts +2 -0
  19. package/src/http/memory.ts +116 -7
  20. package/src/providers/claude-adapter.ts +13 -2
  21. package/src/providers/pricing-sources.md +27 -9
  22. package/src/providers/types.ts +1 -0
  23. package/src/scripts-runtime/swarm-sdk.ts +5 -1
  24. package/src/scripts-runtime/types/stdlib.d.ts +2 -1
  25. package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -1
  26. package/src/server.ts +2 -0
  27. package/src/slack/blocks.ts +58 -12
  28. package/src/slack/responses.ts +35 -12
  29. package/src/slack/watcher.ts +28 -7
  30. package/src/tests/internal-ai/complete-structured.test.ts +34 -1
  31. package/src/tests/memory-http-recall-gating.test.ts +172 -0
  32. package/src/tests/memory-link-resolver.test.ts +92 -0
  33. package/src/tests/opencode-adapter.test.ts +3 -0
  34. package/src/tests/pricing-refresh.test.ts +156 -0
  35. package/src/tests/profile-sync.test.ts +186 -0
  36. package/src/tests/scripts-mcp-e2e.test.ts +1 -1
  37. package/src/tests/slack-blocks.test.ts +48 -1
  38. package/src/tools/memory-get.ts +22 -1
  39. package/src/tools/memory-search.ts +8 -1
  40. package/src/tools/utils.ts +10 -0
  41. package/src/types.ts +2 -0
  42. package/src/utils/internal-ai/complete-structured.ts +10 -1
  43. package/tsconfig.json +1 -0
@@ -51,8 +51,8 @@ export interface SwarmConfig {
51
51
 
52
52
  export interface SwarmSdk {
53
53
  // --- memory ---
54
- memory_search(args: { query: string; scope?: "all" | "agent" | "swarm"; limit?: number; source?: string }): Promise<unknown>;
55
- memory_get(args: { memoryId: string }): Promise<unknown>;
54
+ memory_search(args: { query: string; intent: string; scope?: "all" | "agent" | "swarm"; limit?: number; source?: string }): Promise<unknown>;
55
+ memory_get(args: { memoryId: string; intent: string }): Promise<unknown>;
56
56
  memory_rate(args: { id: string; useful: boolean; note?: string }): Promise<unknown>;
57
57
  // --- tasks ---
58
58
  task_list(args?: Record<string, unknown>): Promise<unknown>;
@@ -176,6 +176,7 @@ export interface SwarmSdk {
176
176
  // --- skills ---
177
177
  skill_list(args?: { scope?: string; scopeId?: string; includeBuiltin?: boolean }): Promise<unknown>;
178
178
  skill_get(args: { id: string }): Promise<unknown>;
179
+ skill_getFile(args: { skillId: string; path: string }): Promise<unknown>;
179
180
  skill_search(args: { query: string; limit?: number }): Promise<unknown>;
180
181
  skill_create(args: Record<string, unknown>): Promise<unknown>;
181
182
  skill_update(args: Record<string, unknown>): Promise<unknown>;
@@ -2,7 +2,9 @@
2
2
  * Phase 2 of the cost-tracking plan — seed the `pricing` table at server boot.
3
3
  *
4
4
  * The vendored models.dev snapshot at `src/be/modelsdev-cache.json` is the
5
- * single source of truth for per-token rates. We project it into rows keyed by
5
+ * cold-start fallback for per-token rates. Runtime freshness is owned by
6
+ * `src/be/pricing-refresh.ts`, which fetches models.dev after boot and inserts
7
+ * newer effective rows when prices change. We project both sources into rows keyed by
6
8
  * `(provider, model, token_class)` so the recompute path in
7
9
  * `src/http/session-data.ts` can rebuild USD from tokens regardless of which
8
10
  * adapter wrote the row.
@@ -74,7 +76,7 @@ const ANTHROPIC_SHORTNAME_TO_MODELSDEV: Record<string, string> = {
74
76
  haiku: "claude-haiku-4-5",
75
77
  };
76
78
 
77
- interface PricingSeedRow {
79
+ export interface PricingSeedRow {
78
80
  provider: PricingProvider;
79
81
  model: string;
80
82
  tokenClass: PricingTokenClass;
@@ -127,7 +129,7 @@ function projectCostBlock(
127
129
  * "what the adapter writes for `model`" and "what models.dev keys by" is
128
130
  * explicit and auditable.
129
131
  */
130
- function buildModelsDevSeedRows(cache: ModelsDevCache): PricingSeedRow[] {
132
+ export function buildModelsDevSeedRows(cache: ModelsDevCache): PricingSeedRow[] {
131
133
  const rows: PricingSeedRow[] = [];
132
134
 
133
135
  // ---- Anthropic / claude family ----------------------------------------
@@ -34,6 +34,42 @@ export const IDENTITY_MD_PATH = "/workspace/IDENTITY.md";
34
34
  export const TOOLS_MD_PATH = "/workspace/TOOLS.md";
35
35
  export const HEARTBEAT_MD_PATH = "/workspace/HEARTBEAT.md";
36
36
  export const SETUP_SCRIPT_PATH = "/workspace/start-up.sh";
37
+
38
+ // ──────────────────────────────────────────────────────────────────────────
39
+ // Identity-file baseline hashes — prevents session-end sync from clobbering
40
+ // DB-side edits made by Lead (via update-profile) during a running session.
41
+ //
42
+ // Flow:
43
+ // 1. Runner writes DB content → /workspace/*.md at session start.
44
+ // 2. Runner records SHA-256 hashes of the written content (the "baselines").
45
+ // 3. At session end, sync compares current file hash against its baseline.
46
+ // - Hash matches → file untouched by the agent → skip sync (preserves
47
+ // any DB-side edits Lead made during the session).
48
+ // - Hash differs → agent modified the file → sync it back to DB.
49
+ // ──────────────────────────────────────────────────────────────────────────
50
+ export const IDENTITY_BASELINES_PATH = "/tmp/identity-baselines.json";
51
+
52
+ export type IdentityBaselines = Record<string, string>;
53
+
54
+ export function contentSha256(content: string): string {
55
+ return new Bun.CryptoHasher("sha256").update(content).digest("hex");
56
+ }
57
+
58
+ export async function writeIdentityBaselines(baselines: IdentityBaselines): Promise<void> {
59
+ await Bun.write(IDENTITY_BASELINES_PATH, JSON.stringify(baselines));
60
+ }
61
+
62
+ export async function readIdentityBaselines(
63
+ readFile: FileReader = readFileIfExists,
64
+ ): Promise<IdentityBaselines | null> {
65
+ try {
66
+ const raw = await readFile(IDENTITY_BASELINES_PATH);
67
+ if (!raw) return null;
68
+ return JSON.parse(raw) as IdentityBaselines;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
37
73
  /**
38
74
  * Claude Code's personal-file CLAUDE.md path. This is what the Claude plugin
39
75
  * Stop hook reads and owns — the runner only uses it as a backstop for an
@@ -135,18 +171,27 @@ export function extractSetupScriptContent(raw: string): string | null {
135
171
  * the trim / max-length guards and the SOUL/IDENTITY min-length guard. Returns
136
172
  * an empty object when nothing is syncable (callers should skip the POST).
137
173
  * `undefined` inputs mean the file was absent.
174
+ *
175
+ * When `baselines` is provided, skips any field whose content hash matches the
176
+ * baseline (i.e. the file was not modified during the session). This prevents
177
+ * session-end sync from clobbering DB-side edits made by Lead.
138
178
  */
139
- export function buildIdentityPayload(files: {
140
- soulMd?: string;
141
- identityMd?: string;
142
- toolsMd?: string;
143
- heartbeatMd?: string;
144
- }): Record<string, string> {
179
+ export function buildIdentityPayload(
180
+ files: {
181
+ soulMd?: string;
182
+ identityMd?: string;
183
+ toolsMd?: string;
184
+ heartbeatMd?: string;
185
+ },
186
+ baselines?: IdentityBaselines | null,
187
+ ): Record<string, string> {
145
188
  const updates: Record<string, string> = {};
146
189
 
147
190
  if (files.soulMd !== undefined) {
148
191
  const content = files.soulMd;
149
- if (content.trim() && content.length <= MAX_FILE_LENGTH) {
192
+ if (baselines?.soulMd && contentSha256(content) === baselines.soulMd) {
193
+ // File unchanged during session — skip to preserve Lead's DB edits
194
+ } else if (content.trim() && content.length <= MAX_FILE_LENGTH) {
150
195
  if (content.length < IDENTITY_FILE_MIN_LENGTH) {
151
196
  console.error(
152
197
  `[profile-sync] Skipping SOUL.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
@@ -159,7 +204,9 @@ export function buildIdentityPayload(files: {
159
204
 
160
205
  if (files.identityMd !== undefined) {
161
206
  const content = files.identityMd;
162
- if (content.trim() && content.length <= MAX_FILE_LENGTH) {
207
+ if (baselines?.identityMd && contentSha256(content) === baselines.identityMd) {
208
+ // File unchanged during session — skip to preserve Lead's DB edits
209
+ } else if (content.trim() && content.length <= MAX_FILE_LENGTH) {
163
210
  if (content.length < IDENTITY_FILE_MIN_LENGTH) {
164
211
  console.error(
165
212
  `[profile-sync] Skipping IDENTITY.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
@@ -172,14 +219,18 @@ export function buildIdentityPayload(files: {
172
219
 
173
220
  if (files.toolsMd !== undefined) {
174
221
  const content = files.toolsMd;
175
- if (content.trim() && content.length <= MAX_FILE_LENGTH) {
222
+ if (baselines?.toolsMd && contentSha256(content) === baselines.toolsMd) {
223
+ // File unchanged during session — skip
224
+ } else if (content.trim() && content.length <= MAX_FILE_LENGTH) {
176
225
  updates.toolsMd = content;
177
226
  }
178
227
  }
179
228
 
180
229
  if (files.heartbeatMd !== undefined) {
181
230
  const content = files.heartbeatMd;
182
- if (content.length <= MAX_FILE_LENGTH) {
231
+ if (baselines?.heartbeatMd && contentSha256(content) === baselines.heartbeatMd) {
232
+ // File unchanged during session — skip
233
+ } else if (content.length <= MAX_FILE_LENGTH) {
183
234
  updates.heartbeatMd = content;
184
235
  }
185
236
  }
@@ -205,6 +256,12 @@ async function readFileIfExists(path: string): Promise<string | undefined> {
205
256
  * Collect the profile-update POST bodies to send. Each entry is one POST.
206
257
  * `fields` selects which groups to include. The file reader is injectable so
207
258
  * the field-selection / guard logic can be unit-tested without touching the FS.
259
+ *
260
+ * When `changeSource` is `"session_sync"`, loads baseline hashes written at
261
+ * session start and skips identity fields whose content hasn't changed — this
262
+ * prevents blind-overwriting DB-side edits made by Lead during the session.
263
+ * On-edit syncs (`"self_edit"`) bypass baselines entirely since the agent
264
+ * explicitly changed the file and the new content should propagate.
208
265
  */
209
266
  export async function collectProfilePayloads(
210
267
  fields: ProfileSyncField[],
@@ -214,13 +271,18 @@ export async function collectProfilePayloads(
214
271
  ): Promise<ProfilePayload[]> {
215
272
  const payloads: ProfilePayload[] = [];
216
273
 
274
+ const baselines = changeSource === "session_sync" ? await readIdentityBaselines(readFile) : null;
275
+
217
276
  if (fields.includes("identity")) {
218
- const updates = buildIdentityPayload({
219
- soulMd: await readFile(SOUL_MD_PATH),
220
- identityMd: await readFile(IDENTITY_MD_PATH),
221
- toolsMd: await readFile(TOOLS_MD_PATH),
222
- heartbeatMd: await readFile(HEARTBEAT_MD_PATH),
223
- });
277
+ const updates = buildIdentityPayload(
278
+ {
279
+ soulMd: await readFile(SOUL_MD_PATH),
280
+ identityMd: await readFile(IDENTITY_MD_PATH),
281
+ toolsMd: await readFile(TOOLS_MD_PATH),
282
+ heartbeatMd: await readFile(HEARTBEAT_MD_PATH),
283
+ },
284
+ baselines,
285
+ );
224
286
  if (Object.keys(updates).length > 0) {
225
287
  payloads.push({ label: "identity", body: { ...updates, changeSource } });
226
288
  }
@@ -229,7 +291,11 @@ export async function collectProfilePayloads(
229
291
  if (fields.includes("claude")) {
230
292
  const raw = await readFile(claudeMdPath);
231
293
  if (raw?.trim() && raw.length <= MAX_FILE_LENGTH) {
232
- payloads.push({ label: "claude", body: { claudeMd: raw, changeSource } });
294
+ if (baselines?.claudeMd && contentSha256(raw) === baselines.claudeMd) {
295
+ // CLAUDE.md unchanged during session — skip to preserve Lead's DB edits
296
+ } else {
297
+ payloads.push({ label: "claude", body: { claudeMd: raw, changeSource } });
298
+ }
233
299
  }
234
300
  }
235
301
 
@@ -57,7 +57,12 @@ import { validateJsonSchema } from "../workflows/json-schema-validator.ts";
57
57
  import { interpolate } from "../workflows/template.ts";
58
58
  import { buildContextPreamble, buildResumeContextPreamble } from "./context-preamble.ts";
59
59
  import { awaitCredentials, BootMaxWaitExceededError, EX_CONFIG } from "./credential-wait.ts";
60
- import { resolveClaudeMdPath, syncProfileFilesToServer } from "./profile-sync.ts";
60
+ import {
61
+ contentSha256,
62
+ resolveClaudeMdPath,
63
+ syncProfileFilesToServer,
64
+ writeIdentityBaselines,
65
+ } from "./profile-sync.ts";
61
66
  import {
62
67
  buildCredStatusReport,
63
68
  buildLatestModelReport,
@@ -1312,6 +1317,7 @@ async function getPausedTasksFromAPI(config: ApiConfig): Promise<
1312
1317
  finishedAt?: string;
1313
1318
  output?: string;
1314
1319
  status?: string;
1320
+ contextKey?: string;
1315
1321
  }>
1316
1322
  > {
1317
1323
  const headers: Record<string, string> = {
@@ -2324,6 +2330,7 @@ async function fetchRelevantMemories(
2324
2330
  agentId: string,
2325
2331
  taskDescription: string,
2326
2332
  taskId?: string,
2333
+ contextKey?: string,
2327
2334
  ): Promise<string | null> {
2328
2335
  try {
2329
2336
  const headers: Record<string, string> = {
@@ -2336,11 +2343,12 @@ async function fetchRelevantMemories(
2336
2343
  // memories they surface against this task's session_logs at completion.
2337
2344
  // Plan: thoughts/taras/plans/2026-05-05-memory-rater-v1.5/step-2.md §2
2338
2345
  if (taskId) headers["X-Source-Task-ID"] = taskId;
2346
+ if (contextKey) headers["X-Context-Key"] = contextKey;
2339
2347
 
2340
2348
  const response = await fetch(`${apiUrl}/api/memory/search`, {
2341
2349
  method: "POST",
2342
2350
  headers,
2343
- body: JSON.stringify({ query: taskDescription, limit: 5 }),
2351
+ body: JSON.stringify({ query: taskDescription, limit: 5, intent: "pre-task memory recall" }),
2344
2352
  });
2345
2353
 
2346
2354
  if (!response.ok) return null;
@@ -2595,6 +2603,7 @@ async function spawnProviderProcess(
2595
2603
  harnessProvider: ProviderName;
2596
2604
  cwd?: string;
2597
2605
  vcsRepo?: string;
2606
+ contextKey?: string;
2598
2607
  },
2599
2608
  logDir: string,
2600
2609
  isYolo: boolean,
@@ -2683,6 +2692,7 @@ async function spawnProviderProcess(
2683
2692
  // Propagate the selected OAuth slot so the adapter refreshes back to the
2684
2693
  // correct pool key. Undefined for non-codex providers and single-cred deploys.
2685
2694
  codexSlot: oauthSelection?.index,
2695
+ contextKey: opts.contextKey,
2686
2696
  };
2687
2697
 
2688
2698
  // Create the long-lived `worker.session` span up front so the provider
@@ -4307,6 +4317,23 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4307
4317
  }
4308
4318
  }
4309
4319
 
4320
+ // Record baseline hashes of identity files as written from DB. Session-end
4321
+ // sync compares current file content against these baselines: unchanged files
4322
+ // are skipped, which prevents clobbering DB-side edits made by Lead via
4323
+ // update-profile during the running session.
4324
+ try {
4325
+ const baselines: Record<string, string> = {};
4326
+ if (agentSoulMd) baselines.soulMd = contentSha256(agentSoulMd);
4327
+ if (agentIdentityMd) baselines.identityMd = contentSha256(agentIdentityMd);
4328
+ if (agentToolsMd) baselines.toolsMd = contentSha256(agentToolsMd);
4329
+ if (agentHeartbeatMd) baselines.heartbeatMd = contentSha256(agentHeartbeatMd);
4330
+ if (agentClaudeMd) baselines.claudeMd = contentSha256(agentClaudeMd);
4331
+ await writeIdentityBaselines(baselines);
4332
+ console.log(`[${role}] Recorded identity file baselines for session-end sync`);
4333
+ } catch {
4334
+ // Non-fatal — worst case, session-end sync proceeds as before (blind overwrite)
4335
+ }
4336
+
4310
4337
  // ========== Boot-time skill load (signature-gated, replaces the standalone
4311
4338
  // skill-fetch + FS sync blocks). The polling loop below calls the same
4312
4339
  // helper per task to hot-reload skills mid-flight. Skipped for
@@ -4390,6 +4417,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4390
4417
  agentId,
4391
4418
  task.task,
4392
4419
  task.id,
4420
+ (task as { contextKey?: string }).contextKey,
4393
4421
  );
4394
4422
  if (resumeMemoryContext) {
4395
4423
  resumePrompt += resumeMemoryContext;
@@ -4515,6 +4543,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4515
4543
  harnessProvider: state.harnessProvider,
4516
4544
  cwd: resumeCwd,
4517
4545
  vcsRepo: task.vcsRepo,
4546
+ contextKey: (task as { contextKey?: string }).contextKey,
4518
4547
  },
4519
4548
  logDir,
4520
4549
  isYolo,
@@ -4780,7 +4809,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4780
4809
  if (trigger.type === "task_assigned" || trigger.type === "task_offered") {
4781
4810
  const task =
4782
4811
  trigger.task && typeof trigger.task === "object" && "task" in trigger.task
4783
- ? (trigger.task as { task: string; id?: string })
4812
+ ? (trigger.task as { task: string; id?: string; contextKey?: string })
4784
4813
  : null;
4785
4814
  if (task?.task) {
4786
4815
  const memoryContext = await fetchRelevantMemories(
@@ -4789,6 +4818,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4789
4818
  agentId,
4790
4819
  task.task,
4791
4820
  task.id,
4821
+ task.contextKey,
4792
4822
  );
4793
4823
  if (memoryContext) {
4794
4824
  triggerPrompt += memoryContext;
@@ -4848,6 +4878,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4848
4878
  // Extract model from task data for per-task model selection
4849
4879
  const taskModel = (trigger.task as { model?: string } | undefined)?.model;
4850
4880
  const taskModelTier = (trigger.task as { modelTier?: string } | undefined)?.modelTier;
4881
+ const taskContextKey = (trigger.task as { contextKey?: string } | undefined)?.contextKey;
4851
4882
 
4852
4883
  // Detect Slack context for conditional prompt sections
4853
4884
  const taskSlackChannelId = (trigger.task as { slackChannelId?: string } | undefined)
@@ -4994,6 +5025,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4994
5025
  harnessProvider: state.harnessProvider,
4995
5026
  cwd: effectiveCwd,
4996
5027
  vcsRepo: taskVcsRepo,
5028
+ contextKey: taskContextKey,
4997
5029
  },
4998
5030
  logDir,
4999
5031
  isYolo,
@@ -361,6 +361,11 @@ export async function startDetachedProcess(opts: StartDetachedOptions): Promise<
361
361
  cwd: opts.cwd ?? "/",
362
362
  envs: opts.env,
363
363
  background: true,
364
+ // CRITICAL: the SDK's default `timeoutMs` is 60s and applies to background
365
+ // commands too — envd kills the whole tracked tree (entrypoint + children)
366
+ // when it expires, silently stopping the worker runner ~60s after boot.
367
+ // 0 disables the limit; sandbox lifetime is governed by its own TTL.
368
+ timeoutMs: 0,
364
369
  });
365
370
 
366
371
  // Early liveness poll: give the entrypoint a moment to fault, then check the
package/src/hooks/hook.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  postRatings,
10
10
  type RetrievalRow,
11
11
  } from "../be/memory/raters/llm";
12
+ import { contentSha256, readIdentityBaselines } from "../commands/profile-sync";
12
13
  import type { Agent } from "../types";
13
14
  import { getApiKey } from "../utils/api-key";
14
15
  import { getMcpBaseUrl } from "../utils/constants";
@@ -581,7 +582,12 @@ export async function handleHook(): Promise<void> {
581
582
  const IDENTITY_FILE_MIN_LENGTH = 500;
582
583
 
583
584
  /**
584
- * Sync SOUL.md and IDENTITY.md content back to the server
585
+ * Sync SOUL.md and IDENTITY.md content back to the server.
586
+ *
587
+ * When `changeSource` is `"session_sync"` (the Stop-hook default), loads
588
+ * baseline hashes written at session start and skips any file whose content
589
+ * hasn't changed. This prevents the session-end sync from clobbering DB-side
590
+ * edits that Lead made via `update-profile` during the running session.
585
591
  */
586
592
  const syncIdentityFilesToServer = async (
587
593
  agentId: string,
@@ -589,12 +595,16 @@ export async function handleHook(): Promise<void> {
589
595
  ): Promise<void> => {
590
596
  if (!mcpConfig) return;
591
597
 
598
+ const baselines = changeSource === "session_sync" ? await readIdentityBaselines() : null;
599
+
592
600
  const updates: Record<string, string> = {};
593
601
 
594
602
  const soulFile = Bun.file(SOUL_MD_PATH);
595
603
  if (await soulFile.exists()) {
596
604
  const content = await soulFile.text();
597
- if (content.trim() && content.length <= 65536) {
605
+ if (baselines?.soulMd && contentSha256(content) === baselines.soulMd) {
606
+ // Unchanged during session — skip to preserve Lead's DB edits
607
+ } else if (content.trim() && content.length <= 65536) {
598
608
  if (content.length < IDENTITY_FILE_MIN_LENGTH) {
599
609
  console.error(
600
610
  `[hook] Skipping SOUL.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
@@ -608,7 +618,9 @@ export async function handleHook(): Promise<void> {
608
618
  const identityFile = Bun.file(IDENTITY_MD_PATH);
609
619
  if (await identityFile.exists()) {
610
620
  const content = await identityFile.text();
611
- if (content.trim() && content.length <= 65536) {
621
+ if (baselines?.identityMd && contentSha256(content) === baselines.identityMd) {
622
+ // Unchanged during session — skip
623
+ } else if (content.trim() && content.length <= 65536) {
612
624
  if (content.length < IDENTITY_FILE_MIN_LENGTH) {
613
625
  console.error(
614
626
  `[hook] Skipping IDENTITY.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
@@ -622,7 +634,9 @@ export async function handleHook(): Promise<void> {
622
634
  const toolsMdFile = Bun.file(TOOLS_MD_PATH);
623
635
  if (await toolsMdFile.exists()) {
624
636
  const content = await toolsMdFile.text();
625
- if (content.trim() && content.length <= 65536) {
637
+ if (baselines?.toolsMd && contentSha256(content) === baselines.toolsMd) {
638
+ // Unchanged during session — skip
639
+ } else if (content.trim() && content.length <= 65536) {
626
640
  updates.toolsMd = content;
627
641
  }
628
642
  }
@@ -630,7 +644,9 @@ export async function handleHook(): Promise<void> {
630
644
  const heartbeatFile = Bun.file(HEARTBEAT_MD_PATH);
631
645
  if (await heartbeatFile.exists()) {
632
646
  const content = await heartbeatFile.text();
633
- if (content.length <= 65536) {
647
+ if (baselines?.heartbeatMd && contentSha256(content) === baselines.heartbeatMd) {
648
+ // Unchanged during session — skip
649
+ } else if (content.length <= 65536) {
634
650
  updates.heartbeatMd = content;
635
651
  }
636
652
  }
package/src/http/index.ts CHANGED
@@ -451,6 +451,8 @@ try {
451
451
  try {
452
452
  const { seedPricingFromModelsDev } = await import("../be/seed-pricing");
453
453
  seedPricingFromModelsDev();
454
+ const { startPricingRefreshLoop } = await import("../be/pricing-refresh");
455
+ startPricingRefreshLoop();
454
456
  } catch (err) {
455
457
  console.error("[startup] Failed to seed pricing rows:", err);
456
458
  }
@@ -1,10 +1,11 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
3
  import { chunkContent } from "../be/chunking";
4
- import { getTaskById } from "../be/db";
4
+ import { getDb, getTaskById } from "../be/db";
5
5
  import { getEmbeddingProvider, getMemoryStore } from "../be/memory";
6
6
  import { CANDIDATE_SET_MULTIPLIER } from "../be/memory/constants";
7
7
  import { listEdgesForAgent } from "../be/memory/edges-store";
8
+ import { storeLinks } from "../be/memory/link-resolver";
8
9
  import { recordRetrievals } from "../be/memory/raters/retrieval";
9
10
  import { applyRating, ExplicitSelfDuplicateError } from "../be/memory/raters/store";
10
11
  import {
@@ -37,6 +38,7 @@ const indexMemory = route({
37
38
  sourcePath: z.string().optional(),
38
39
  tags: z.array(z.string()).optional(),
39
40
  persistMemory: z.boolean().optional(),
41
+ contextKey: z.string().optional(),
40
42
  }),
41
43
  responses: {
42
44
  202: { description: "Content queued for embedding" },
@@ -53,6 +55,13 @@ const searchMemory = route({
53
55
  auth: { apiKey: true, agentId: true },
54
56
  body: z.object({
55
57
  query: z.string().min(1),
58
+ intent: z
59
+ .string()
60
+ .min(1)
61
+ .optional()
62
+ .describe(
63
+ "Why you are searching. Required for agent recall-edge tracking; omit for UI browse/search calls.",
64
+ ),
56
65
  limit: z.number().int().min(1).max(20).default(5),
57
66
  scope: z.enum(["agent", "swarm", "all"]).default("all"),
58
67
  source: z.enum(["manual", "file_index", "session_summary", "task_completion"]).optional(),
@@ -149,6 +158,15 @@ const getMemoryById = route({
149
158
  tags: ["Memory"],
150
159
  auth: { apiKey: true, agentId: true },
151
160
  params: z.object({ id: z.string().uuid() }),
161
+ query: z.object({
162
+ intent: z
163
+ .string()
164
+ .min(1)
165
+ .optional()
166
+ .describe(
167
+ "Why you are retrieving this memory. Required for agent recall-edge tracking; omit for UI browse calls.",
168
+ ),
169
+ }),
152
170
  responses: {
153
171
  200: { description: "Memory details" },
154
172
  404: { description: "Memory not found" },
@@ -266,8 +284,18 @@ export async function handleMemory(
266
284
  const parsed = await indexMemory.parse(req, res, pathSegments, new URLSearchParams());
267
285
  if (!parsed) return true;
268
286
 
269
- const { agentId, content, name, scope, source, sourceTaskId, sourcePath, tags, persistMemory } =
270
- parsed.body;
287
+ const {
288
+ agentId,
289
+ content,
290
+ name,
291
+ scope,
292
+ source,
293
+ sourceTaskId,
294
+ sourcePath,
295
+ tags,
296
+ persistMemory,
297
+ contextKey,
298
+ } = parsed.body;
271
299
 
272
300
  if (source === "session_summary" && sourceTaskId) {
273
301
  const sourceTask = getTaskById(sourceTaskId);
@@ -296,6 +324,13 @@ export async function handleMemory(
296
324
  store.deleteBySourcePath(sourcePath, agentId);
297
325
  }
298
326
 
327
+ // Derive contextKey from body or X-Context-Key header
328
+ const headerContextKey = req.headers["x-context-key"];
329
+ const resolvedContextKey =
330
+ contextKey ??
331
+ (Array.isArray(headerContextKey) ? headerContextKey[0] : headerContextKey) ??
332
+ undefined;
333
+
299
334
  // Atomic batch insert — all chunks or none
300
335
  const memories = store.storeBatch(
301
336
  contentChunks.map((chunk) => ({
@@ -309,9 +344,24 @@ export async function handleMemory(
309
344
  chunkIndex: chunk.chunkIndex,
310
345
  totalChunks: chunk.totalChunks,
311
346
  tags: tags || [],
347
+ contextKey: resolvedContextKey ?? null,
312
348
  })),
313
349
  );
314
350
 
351
+ // Resolve and store deterministic links (wikilinks, PR refs, agent-fs paths)
352
+ if (agentId) {
353
+ for (const memory of memories) {
354
+ try {
355
+ storeLinks(memory.id, agentId, memory.content);
356
+ } catch (err) {
357
+ console.error(
358
+ `[memory] Link resolution failed for ${memory.id}:`,
359
+ (err as Error).message,
360
+ );
361
+ }
362
+ }
363
+ }
364
+
315
365
  // Async batch embed (fire and forget)
316
366
  (async () => {
317
367
  try {
@@ -339,7 +389,7 @@ export async function handleMemory(
339
389
  const parsed = await searchMemory.parse(req, res, pathSegments, new URLSearchParams());
340
390
  if (!parsed) return true;
341
391
 
342
- const { query, limit, scope, source } = parsed.body;
392
+ const { query, intent, limit, scope, source } = parsed.body;
343
393
 
344
394
  try {
345
395
  const provider = getEmbeddingProvider();
@@ -369,12 +419,16 @@ export async function handleMemory(
369
419
  const sourceTaskId = Array.isArray(sourceTaskIdHeader)
370
420
  ? sourceTaskIdHeader[0]
371
421
  : sourceTaskIdHeader;
372
- if (sourceTaskId) {
422
+ const contextKeyHeader = req.headers["x-context-key"];
423
+ const contextKey = Array.isArray(contextKeyHeader) ? contextKeyHeader[0] : contextKeyHeader;
424
+ if (sourceTaskId && intent) {
373
425
  try {
374
426
  recordRetrievals(
375
427
  sourceTaskId,
376
428
  myAgentId,
377
429
  ranked.map((r) => ({ memoryId: r.id, similarity: r.similarity })),
430
+ undefined,
431
+ { intent, contextKey, eventType: "search" },
378
432
  );
379
433
  } catch (err) {
380
434
  console.error("[memory-search] recordRetrievals failed:", (err as Error).message);
@@ -620,7 +674,11 @@ export async function handleMemory(
620
674
  reasoning: e.reasoning,
621
675
  ...(e.referencesSource !== undefined ? { referencesSource: e.referencesSource } : {}),
622
676
  }));
623
- const result = applyRating(ratingEvents, { taskId });
677
+ const rateContextKeyHeader = req.headers["x-context-key"];
678
+ const rateContextKey = Array.isArray(rateContextKeyHeader)
679
+ ? rateContextKeyHeader[0]
680
+ : rateContextKeyHeader;
681
+ const result = applyRating(ratingEvents, { taskId, contextKey: rateContextKey });
624
682
  applied += result.applied;
625
683
  for (const r of result.rejected) {
626
684
  rejected.push({ memoryId: r.event.memoryId, reason: r.reason });
@@ -671,7 +729,8 @@ export async function handleMemory(
671
729
  }
672
730
 
673
731
  if (getMemoryById.match(req.method, pathSegments)) {
674
- const parsed = await getMemoryById.parse(req, res, pathSegments, new URLSearchParams());
732
+ const queryParams = parseQueryParams(req.url || "");
733
+ const parsed = await getMemoryById.parse(req, res, pathSegments, queryParams);
675
734
  if (!parsed) return true;
676
735
 
677
736
  const memory = getMemoryStore().get(parsed.params.id);
@@ -680,6 +739,27 @@ export async function handleMemory(
680
739
  return true;
681
740
  }
682
741
 
742
+ const { intent } = parsed.query;
743
+ const sourceTaskIdHeader = req.headers["x-source-task-id"];
744
+ const sourceTaskId = Array.isArray(sourceTaskIdHeader)
745
+ ? sourceTaskIdHeader[0]
746
+ : sourceTaskIdHeader;
747
+ const contextKeyHeader = req.headers["x-context-key"];
748
+ const contextKey = Array.isArray(contextKeyHeader) ? contextKeyHeader[0] : contextKeyHeader;
749
+ if (sourceTaskId && myAgentId && intent) {
750
+ try {
751
+ recordRetrievals(
752
+ sourceTaskId,
753
+ myAgentId,
754
+ [{ memoryId: memory.id, similarity: 1.0 }],
755
+ undefined,
756
+ { intent, contextKey, eventType: "get" },
757
+ );
758
+ } catch (err) {
759
+ console.error("[memory-get] recordRetrievals failed:", (err as Error).message);
760
+ }
761
+ }
762
+
683
763
  json(res, { memory });
684
764
  return true;
685
765
  }
@@ -692,6 +772,23 @@ export async function handleMemory(
692
772
  const MEMORY_GC_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
693
773
  let memoryGcTimer: ReturnType<typeof setInterval> | null = null;
694
774
 
775
+ const SEARCH_RETRIEVAL_TTL_DAYS = 90;
776
+
777
+ function purgeStaleSearchRetrievals(): number {
778
+ try {
779
+ const cutoff = new Date(
780
+ Date.now() - SEARCH_RETRIEVAL_TTL_DAYS * 24 * 60 * 60 * 1000,
781
+ ).toISOString();
782
+ const result = getDb()
783
+ .prepare("DELETE FROM memory_retrieval WHERE eventType = 'search' AND retrievedAt < ?")
784
+ .run(cutoff);
785
+ return result.changes;
786
+ } catch (err) {
787
+ console.error("[memory-gc] Search retrieval purge failed:", (err as Error).message);
788
+ return 0;
789
+ }
790
+ }
791
+
695
792
  export function startMemoryGc(intervalMs = MEMORY_GC_INTERVAL_MS): void {
696
793
  if (memoryGcTimer) return;
697
794
 
@@ -701,6 +798,12 @@ export function startMemoryGc(intervalMs = MEMORY_GC_INTERVAL_MS): void {
701
798
  if (purged > 0) {
702
799
  console.log(`[memory-gc] Initial purge removed ${purged} expired memory row(s)`);
703
800
  }
801
+ const searchPurged = purgeStaleSearchRetrievals();
802
+ if (searchPurged > 0) {
803
+ console.log(
804
+ `[memory-gc] Initial purge removed ${searchPurged} stale search retrieval row(s)`,
805
+ );
806
+ }
704
807
  } catch (err) {
705
808
  console.error("[memory-gc] Initial purge failed:", err);
706
809
  }
@@ -711,6 +814,12 @@ export function startMemoryGc(intervalMs = MEMORY_GC_INTERVAL_MS): void {
711
814
  if (purged > 0) {
712
815
  console.log(`[memory-gc] Periodic purge removed ${purged} expired memory row(s)`);
713
816
  }
817
+ const searchPurged = purgeStaleSearchRetrievals();
818
+ if (searchPurged > 0) {
819
+ console.log(
820
+ `[memory-gc] Periodic purge removed ${searchPurged} stale search retrieval row(s)`,
821
+ );
822
+ }
714
823
  } catch (err) {
715
824
  console.error("[memory-gc] Periodic purge failed:", err);
716
825
  }