@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.
- package/README.md +1 -0
- package/openapi.json +20 -1
- package/package.json +5 -5
- package/src/be/memory/link-resolver.ts +226 -0
- package/src/be/memory/providers/sqlite-store.ts +4 -2
- package/src/be/memory/raters/retrieval.ts +15 -4
- package/src/be/memory/raters/store.ts +4 -2
- package/src/be/memory/types.ts +1 -0
- package/src/be/migrations/096_memory_graph_phase1.sql +50 -0
- package/src/be/modelsdev-cache.ts +5 -0
- package/src/be/pricing-refresh.ts +189 -0
- package/src/be/scripts/typecheck.ts +3 -2
- package/src/be/seed-pricing.ts +5 -3
- package/src/commands/profile-sync.ts +83 -17
- package/src/commands/runner.ts +35 -3
- package/src/e2b/dispatch.ts +5 -0
- package/src/hooks/hook.ts +21 -5
- package/src/http/index.ts +2 -0
- package/src/http/memory.ts +116 -7
- package/src/providers/claude-adapter.ts +13 -2
- package/src/providers/pricing-sources.md +27 -9
- package/src/providers/types.ts +1 -0
- package/src/scripts-runtime/swarm-sdk.ts +5 -1
- package/src/scripts-runtime/types/stdlib.d.ts +2 -1
- package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -1
- package/src/server.ts +2 -0
- package/src/slack/blocks.ts +58 -12
- package/src/slack/responses.ts +35 -12
- package/src/slack/watcher.ts +28 -7
- package/src/tests/internal-ai/complete-structured.test.ts +34 -1
- package/src/tests/memory-http-recall-gating.test.ts +172 -0
- package/src/tests/memory-link-resolver.test.ts +92 -0
- package/src/tests/opencode-adapter.test.ts +3 -0
- package/src/tests/pricing-refresh.test.ts +156 -0
- package/src/tests/profile-sync.test.ts +186 -0
- package/src/tests/scripts-mcp-e2e.test.ts +1 -1
- package/src/tests/slack-blocks.test.ts +48 -1
- package/src/tools/memory-get.ts +22 -1
- package/src/tools/memory-search.ts +8 -1
- package/src/tools/utils.ts +10 -0
- package/src/types.ts +2 -0
- package/src/utils/internal-ai/complete-structured.ts +10 -1
- 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>;
|
package/src/be/seed-pricing.ts
CHANGED
|
@@ -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
|
-
*
|
|
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(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
|
package/src/commands/runner.ts
CHANGED
|
@@ -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 {
|
|
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,
|
package/src/e2b/dispatch.ts
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
}
|
package/src/http/memory.ts
CHANGED
|
@@ -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 {
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|