@desplega.ai/agent-swarm 1.92.1 → 1.92.2
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/openapi.json +1 -1
- package/package.json +1 -1
- package/src/be/db.ts +89 -0
- package/src/be/memory/boot-reembed.ts +85 -0
- package/src/be/memory/constants.ts +42 -1
- package/src/be/memory/providers/openai-embedding.ts +13 -0
- package/src/be/memory/providers/sqlite-store.ts +33 -1
- package/src/be/memory/reranker.ts +35 -17
- package/src/be/memory/types.ts +8 -0
- package/src/be/modelsdev-cache.json +5308 -2165
- package/src/be/seed-scripts/catalog/compound-insights.ts +371 -0
- package/src/http/index.ts +9 -0
- package/src/http/memory.ts +4 -0
- package/src/tasks/worker-follow-up.ts +12 -0
- package/src/tests/memory-e2e.test.ts +6 -6
- package/src/tests/memory-rater-e2e.test.ts +4 -5
- package/src/tests/memory-reranker.test.ts +135 -124
- package/src/tests/memory.test.ts +13 -12
- package/src/tests/seed-scripts.test.ts +205 -0
- package/src/tests/task-cascade-fail.test.ts +304 -0
- package/templates/workflows/llm-safe-release-context/config.json +13 -0
- package/templates/workflows/llm-safe-release-context/content.md +69 -0
package/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Swarm API",
|
|
5
|
-
"version": "1.92.
|
|
5
|
+
"version": "1.92.2",
|
|
6
6
|
"description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
|
|
7
7
|
},
|
|
8
8
|
"servers": [
|
package/package.json
CHANGED
package/src/be/db.ts
CHANGED
|
@@ -2117,6 +2117,14 @@ export function failTask(id: string, reason: string): AgentTask | null {
|
|
|
2117
2117
|
});
|
|
2118
2118
|
});
|
|
2119
2119
|
} catch {}
|
|
2120
|
+
|
|
2121
|
+
// Cascade-fail any non-terminal tasks that depend on this one.
|
|
2122
|
+
// The cascade is recursive (transitive closure) and cycle-safe.
|
|
2123
|
+
try {
|
|
2124
|
+
cascadeFailDependents(id, "failed");
|
|
2125
|
+
} catch (err) {
|
|
2126
|
+
console.error("[failTask] cascade-fail dependents error:", err);
|
|
2127
|
+
}
|
|
2120
2128
|
}
|
|
2121
2129
|
return row ? rowToAgentTask(row) : null;
|
|
2122
2130
|
}
|
|
@@ -2155,6 +2163,12 @@ export function cancelTask(id: string, reason?: string): AgentTask | null {
|
|
|
2155
2163
|
});
|
|
2156
2164
|
});
|
|
2157
2165
|
} catch {}
|
|
2166
|
+
|
|
2167
|
+
try {
|
|
2168
|
+
cascadeFailDependents(id, "cancelled");
|
|
2169
|
+
} catch (err) {
|
|
2170
|
+
console.error("[cancelTask] cascade-fail dependents error:", err);
|
|
2171
|
+
}
|
|
2158
2172
|
}
|
|
2159
2173
|
|
|
2160
2174
|
return row ? rowToAgentTask(row) : null;
|
|
@@ -2218,6 +2232,12 @@ export function supersedeTask(
|
|
|
2218
2232
|
});
|
|
2219
2233
|
});
|
|
2220
2234
|
} catch {}
|
|
2235
|
+
|
|
2236
|
+
try {
|
|
2237
|
+
cascadeFailDependents(id, "superseded");
|
|
2238
|
+
} catch (err) {
|
|
2239
|
+
console.error("[supersedeTask] cascade-fail dependents error:", err);
|
|
2240
|
+
}
|
|
2221
2241
|
}
|
|
2222
2242
|
|
|
2223
2243
|
return row ? rowToAgentTask(row) : null;
|
|
@@ -3390,6 +3410,75 @@ export function checkDependencies(taskId: string): {
|
|
|
3390
3410
|
return { ready: blockedBy.length === 0, blockedBy };
|
|
3391
3411
|
}
|
|
3392
3412
|
|
|
3413
|
+
/**
|
|
3414
|
+
* Reverse-lookup: find all tasks whose `dependsOn` JSON array contains `parentId`.
|
|
3415
|
+
* Uses SQLite `json_each` to scan the dependsOn column efficiently.
|
|
3416
|
+
* Returns only non-terminal tasks by default (the callers want to cascade-fail
|
|
3417
|
+
* live dependents, not re-process already-finished ones).
|
|
3418
|
+
*/
|
|
3419
|
+
export function getDependentTasks(
|
|
3420
|
+
parentId: string,
|
|
3421
|
+
opts?: { includeTerminal?: boolean },
|
|
3422
|
+
): AgentTask[] {
|
|
3423
|
+
const database = getDb();
|
|
3424
|
+
const rows = database
|
|
3425
|
+
.prepare<AgentTaskRow, [string]>(
|
|
3426
|
+
`SELECT t.*
|
|
3427
|
+
FROM agent_tasks t, json_each(t.dependsOn) AS dep
|
|
3428
|
+
WHERE dep.value = ?`,
|
|
3429
|
+
)
|
|
3430
|
+
.all(parentId);
|
|
3431
|
+
|
|
3432
|
+
const tasks = rows.map(rowToAgentTask);
|
|
3433
|
+
if (opts?.includeTerminal) return tasks;
|
|
3434
|
+
return tasks.filter((t) => !isTerminalTaskStatus(t.status));
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
export interface CascadeFailResult {
|
|
3438
|
+
taskId: string;
|
|
3439
|
+
taskSubject: string;
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3442
|
+
/**
|
|
3443
|
+
* Recursively cascade-fail all transitive dependents of a parent task.
|
|
3444
|
+
* Walks the full dependency graph: if A fails, and B depends on A, and C
|
|
3445
|
+
* depends on B, then both B and C are failed.
|
|
3446
|
+
*
|
|
3447
|
+
* Guards against cycles with a visited set. Skips already-terminal tasks.
|
|
3448
|
+
* Returns the list of tasks that were actually cascade-failed (for follow-up
|
|
3449
|
+
* enrichment).
|
|
3450
|
+
*/
|
|
3451
|
+
export function cascadeFailDependents(
|
|
3452
|
+
parentId: string,
|
|
3453
|
+
parentStatus: string,
|
|
3454
|
+
visited?: Set<string>,
|
|
3455
|
+
): CascadeFailResult[] {
|
|
3456
|
+
const seen = visited ?? new Set<string>();
|
|
3457
|
+
if (seen.has(parentId)) return [];
|
|
3458
|
+
seen.add(parentId);
|
|
3459
|
+
|
|
3460
|
+
const dependents = getDependentTasks(parentId);
|
|
3461
|
+
const results: CascadeFailResult[] = [];
|
|
3462
|
+
|
|
3463
|
+
for (const dep of dependents) {
|
|
3464
|
+
if (seen.has(dep.id)) continue;
|
|
3465
|
+
|
|
3466
|
+
const reason = `Blocked dependency ${parentId.slice(0, 8)} was ${parentStatus}`;
|
|
3467
|
+
const failed = failTask(dep.id, reason);
|
|
3468
|
+
if (failed) {
|
|
3469
|
+
results.push({
|
|
3470
|
+
taskId: failed.id,
|
|
3471
|
+
taskSubject: failed.task.slice(0, 120),
|
|
3472
|
+
});
|
|
3473
|
+
// Recurse: this dependent may itself have dependents
|
|
3474
|
+
const transitive = cascadeFailDependents(dep.id, "failed (cascade)", seen);
|
|
3475
|
+
results.push(...transitive);
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
return results;
|
|
3480
|
+
}
|
|
3481
|
+
|
|
3393
3482
|
// ============================================================================
|
|
3394
3483
|
// Agent Profile Operations
|
|
3395
3484
|
// ============================================================================
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup backfill: detect agent_memory rows with wrong-dimension embeddings
|
|
3
|
+
* (not 512d) and re-embed them in the background. Runs once per boot,
|
|
4
|
+
* async/non-blocking, idempotent, no-op when the DB is clean.
|
|
5
|
+
*
|
|
6
|
+
* This is the app-level equivalent of a forward-only migration — SQL can't
|
|
7
|
+
* call OpenAI, so the backfill runs at startup instead.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getDb } from "@/be/db";
|
|
11
|
+
import { EMBEDDING_DIMENSIONS } from "./constants";
|
|
12
|
+
import { getEmbeddingProvider, getMemoryStore } from "./index";
|
|
13
|
+
|
|
14
|
+
const VECTOR_BYTES = EMBEDDING_DIMENSIONS * Float32Array.BYTES_PER_ELEMENT;
|
|
15
|
+
const BATCH_SIZE = 20;
|
|
16
|
+
const BACKFILL_KV_KEY = "memory:reembed:backfill_complete";
|
|
17
|
+
|
|
18
|
+
export async function runBootReembed(): Promise<void> {
|
|
19
|
+
const db = getDb();
|
|
20
|
+
|
|
21
|
+
const invalidCount =
|
|
22
|
+
db
|
|
23
|
+
.prepare<{ count: number }, []>(
|
|
24
|
+
`SELECT COUNT(*) as count FROM agent_memory
|
|
25
|
+
WHERE embedding IS NOT NULL AND length(embedding) != ${VECTOR_BYTES}`,
|
|
26
|
+
)
|
|
27
|
+
.get()?.count ?? 0;
|
|
28
|
+
|
|
29
|
+
if (invalidCount === 0) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const provider = getEmbeddingProvider();
|
|
34
|
+
const testEmbed = await provider.embed("test");
|
|
35
|
+
if (!testEmbed) {
|
|
36
|
+
console.warn(
|
|
37
|
+
`[boot-reembed] skipped: ${invalidCount} wrong-dimension rows found but no OpenAI key configured`,
|
|
38
|
+
);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(`[boot-reembed] starting: ${invalidCount} rows with wrong embedding dimensions`);
|
|
43
|
+
|
|
44
|
+
const store = getMemoryStore();
|
|
45
|
+
const rows = db
|
|
46
|
+
.prepare<{ id: string; content: string }, []>(
|
|
47
|
+
`SELECT id, content FROM agent_memory
|
|
48
|
+
WHERE embedding IS NOT NULL AND length(embedding) != ${VECTOR_BYTES}`,
|
|
49
|
+
)
|
|
50
|
+
.all();
|
|
51
|
+
|
|
52
|
+
let reembedded = 0;
|
|
53
|
+
let failed = 0;
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
|
56
|
+
const batch = rows.slice(i, i + BATCH_SIZE);
|
|
57
|
+
try {
|
|
58
|
+
const embeddings = await provider.embedBatch(batch.map((m) => m.content));
|
|
59
|
+
for (let j = 0; j < embeddings.length; j++) {
|
|
60
|
+
if (embeddings[j]) {
|
|
61
|
+
store.updateEmbedding(batch[j]!.id, embeddings[j]!, provider.name);
|
|
62
|
+
reembedded++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch (err) {
|
|
66
|
+
failed += batch.length;
|
|
67
|
+
console.error(
|
|
68
|
+
`[boot-reembed] batch ${Math.floor(i / BATCH_SIZE) + 1} failed:`,
|
|
69
|
+
(err as Error).message,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const afterInvalid =
|
|
75
|
+
db
|
|
76
|
+
.prepare<{ count: number }, []>(
|
|
77
|
+
`SELECT COUNT(*) as count FROM agent_memory
|
|
78
|
+
WHERE embedding IS NOT NULL AND length(embedding) != ${VECTOR_BYTES}`,
|
|
79
|
+
)
|
|
80
|
+
.get()?.count ?? 0;
|
|
81
|
+
|
|
82
|
+
console.log(
|
|
83
|
+
`[boot-reembed] complete: reembedded=${reembedded} failed=${failed} remaining_invalid=${afterInvalid}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -15,8 +15,46 @@ export const TTL_DEFAULTS: Record<AgentMemorySource, number | null> = {
|
|
|
15
15
|
manual: null,
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
// Per-source recency decay half-life (in days).
|
|
19
|
+
// manual = Infinity (no decay — curated knowledge stays relevant forever).
|
|
20
|
+
// A global MEMORY_RECENCY_HALF_LIFE_DAYS override forces ALL sources to the same value.
|
|
21
|
+
const GLOBAL_HALF_LIFE_OVERRIDE = process.env.MEMORY_RECENCY_HALF_LIFE_DAYS;
|
|
22
|
+
const GLOBAL_HALF_LIFE =
|
|
23
|
+
GLOBAL_HALF_LIFE_OVERRIDE != null && GLOBAL_HALF_LIFE_OVERRIDE !== ""
|
|
24
|
+
? Number(GLOBAL_HALF_LIFE_OVERRIDE)
|
|
25
|
+
: null;
|
|
26
|
+
|
|
27
|
+
export const RECENCY_DECAY_HALF_LIFE: Record<AgentMemorySource, number> =
|
|
28
|
+
GLOBAL_HALF_LIFE != null && Number.isFinite(GLOBAL_HALF_LIFE)
|
|
29
|
+
? {
|
|
30
|
+
manual: GLOBAL_HALF_LIFE,
|
|
31
|
+
file_index: GLOBAL_HALF_LIFE,
|
|
32
|
+
task_completion: GLOBAL_HALF_LIFE,
|
|
33
|
+
session_summary: GLOBAL_HALF_LIFE,
|
|
34
|
+
}
|
|
35
|
+
: {
|
|
36
|
+
manual: Number.POSITIVE_INFINITY,
|
|
37
|
+
file_index: 180,
|
|
38
|
+
task_completion: 14,
|
|
39
|
+
session_summary: 7,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Legacy export — callers that don't have a source fall back to task_completion's value.
|
|
43
|
+
export const RECENCY_DECAY_HALF_LIFE_DAYS = RECENCY_DECAY_HALF_LIFE.task_completion;
|
|
44
|
+
|
|
45
|
+
// Source-quality multiplier for reranking.
|
|
46
|
+
// Curated manual memories rank higher; ephemeral session summaries rank lower.
|
|
47
|
+
export const SOURCE_QUALITY_MULTIPLIER: Record<AgentMemorySource, number> = {
|
|
48
|
+
manual: 1.5,
|
|
49
|
+
file_index: 1.0,
|
|
50
|
+
task_completion: 0.7,
|
|
51
|
+
session_summary: 0.5,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Minimum raw cosine similarity to keep a candidate. Below this, the result is noise.
|
|
55
|
+
export const MIN_SIMILARITY = numEnv("MEMORY_MIN_SIMILARITY", 0.1);
|
|
56
|
+
|
|
18
57
|
// Reranking parameters
|
|
19
|
-
export const RECENCY_DECAY_HALF_LIFE_DAYS = numEnv("MEMORY_RECENCY_HALF_LIFE_DAYS", 14);
|
|
20
58
|
export const ACCESS_BOOST_MAX_MULTIPLIER = numEnv("MEMORY_ACCESS_BOOST_MAX", 1.5);
|
|
21
59
|
export const ACCESS_BOOST_RECENCY_WINDOW_HOURS = numEnv("MEMORY_ACCESS_RECENCY_HOURS", 48);
|
|
22
60
|
export const CANDIDATE_SET_MULTIPLIER = numEnv("MEMORY_CANDIDATE_MULTIPLIER", 3);
|
|
@@ -25,3 +63,6 @@ export const CANDIDATE_SET_MULTIPLIER = numEnv("MEMORY_CANDIDATE_MULTIPLIER", 3)
|
|
|
25
63
|
export const EMBEDDING_DIMENSIONS = numEnv("EMBEDDING_DIMENSIONS", 512);
|
|
26
64
|
export const DEFAULT_EMBEDDING_DIMENSIONS = EMBEDDING_DIMENSIONS;
|
|
27
65
|
export const DEFAULT_EMBEDDING_MODEL = "openai/text-embedding-3-small";
|
|
66
|
+
|
|
67
|
+
// Manual memories must NEVER be deleted by automated processes (curator, GC, etc.)
|
|
68
|
+
export const PROTECTED_SOURCES: ReadonlySet<AgentMemorySource> = new Set(["manual"]);
|
|
@@ -55,6 +55,13 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider {
|
|
|
55
55
|
const values = response.data[0]?.embedding;
|
|
56
56
|
if (!values) return null;
|
|
57
57
|
|
|
58
|
+
if (values.length !== this.dimensions) {
|
|
59
|
+
console.error(
|
|
60
|
+
`[memory] Embedding dimension mismatch: expected=${this.dimensions} got=${values.length}. Provider may not support the 'dimensions' parameter.`,
|
|
61
|
+
);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
58
65
|
return new Float32Array(values);
|
|
59
66
|
} catch (err) {
|
|
60
67
|
console.error("[memory] Embedding failed:", (err as Error).message);
|
|
@@ -90,6 +97,12 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider {
|
|
|
90
97
|
for (const item of response.data) {
|
|
91
98
|
const originalIndex = nonEmptyIndices[item.index];
|
|
92
99
|
if (originalIndex !== undefined && item.embedding) {
|
|
100
|
+
if (item.embedding.length !== this.dimensions) {
|
|
101
|
+
console.error(
|
|
102
|
+
`[memory] Batch embedding dimension mismatch: expected=${this.dimensions} got=${item.embedding.length}. Provider may not support the 'dimensions' parameter.`,
|
|
103
|
+
);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
93
106
|
results[originalIndex] = new Float32Array(item.embedding);
|
|
94
107
|
}
|
|
95
108
|
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { getDb, isSqliteVecAvailable } from "@/be/db";
|
|
2
2
|
import { cosineSimilarity, deserializeEmbedding, serializeEmbedding } from "@/be/embedding";
|
|
3
3
|
import type { AgentMemory, AgentMemoryScope, AgentMemorySource } from "@/types";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
EMBEDDING_DIMENSIONS,
|
|
6
|
+
MIN_SIMILARITY,
|
|
7
|
+
PROTECTED_SOURCES,
|
|
8
|
+
TTL_DEFAULTS,
|
|
9
|
+
} from "../constants";
|
|
5
10
|
import type {
|
|
6
11
|
MemoryCandidate,
|
|
7
12
|
MemoryHealth,
|
|
@@ -400,6 +405,7 @@ export class SqliteMemoryStore implements MemoryStore {
|
|
|
400
405
|
const candidates: MemoryCandidate[] = [];
|
|
401
406
|
for (const row of rows) {
|
|
402
407
|
const similarity = 1 - row.distance;
|
|
408
|
+
if (similarity < MIN_SIMILARITY) continue;
|
|
403
409
|
candidates.push(rowToCandidate(row, similarity));
|
|
404
410
|
}
|
|
405
411
|
|
|
@@ -446,6 +452,7 @@ export class SqliteMemoryStore implements MemoryStore {
|
|
|
446
452
|
const emb = deserializeEmbedding(row.embedding);
|
|
447
453
|
if (emb.length !== queryEmbedding.length) continue;
|
|
448
454
|
const similarity = cosineSimilarity(queryEmbedding, emb);
|
|
455
|
+
if (similarity < MIN_SIMILARITY) continue;
|
|
449
456
|
candidates.push(rowToCandidate(row, similarity));
|
|
450
457
|
}
|
|
451
458
|
|
|
@@ -523,6 +530,31 @@ export class SqliteMemoryStore implements MemoryStore {
|
|
|
523
530
|
return rows.map(rowToAgentMemory);
|
|
524
531
|
}
|
|
525
532
|
|
|
533
|
+
isSourceProtected(source: AgentMemorySource): boolean {
|
|
534
|
+
return PROTECTED_SOURCES.has(source);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
listForCuration(
|
|
538
|
+
agentId?: string,
|
|
539
|
+
): { id: string; source: string; name: string; createdAt: string }[] {
|
|
540
|
+
const db = getDb();
|
|
541
|
+
const protectedList = [...PROTECTED_SOURCES].map((s) => `'${s}'`).join(",");
|
|
542
|
+
if (agentId) {
|
|
543
|
+
return db
|
|
544
|
+
.prepare<{ id: string; source: string; name: string; createdAt: string }, [string]>(
|
|
545
|
+
`SELECT id, source, name, createdAt FROM agent_memory
|
|
546
|
+
WHERE agentId = ? AND source NOT IN (${protectedList})`,
|
|
547
|
+
)
|
|
548
|
+
.all(agentId);
|
|
549
|
+
}
|
|
550
|
+
return db
|
|
551
|
+
.prepare<{ id: string; source: string; name: string; createdAt: string }, []>(
|
|
552
|
+
`SELECT id, source, name, createdAt FROM agent_memory
|
|
553
|
+
WHERE source NOT IN (${protectedList})`,
|
|
554
|
+
)
|
|
555
|
+
.all();
|
|
556
|
+
}
|
|
557
|
+
|
|
526
558
|
listForReembedding(options?: { agentId?: string }): { id: string; content: string }[] {
|
|
527
559
|
const db = getDb();
|
|
528
560
|
if (options?.agentId) {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import type { AgentMemorySource } from "@/types";
|
|
1
2
|
import {
|
|
2
3
|
ACCESS_BOOST_MAX_MULTIPLIER,
|
|
3
4
|
ACCESS_BOOST_RECENCY_WINDOW_HOURS,
|
|
5
|
+
RECENCY_DECAY_HALF_LIFE,
|
|
4
6
|
RECENCY_DECAY_HALF_LIFE_DAYS,
|
|
7
|
+
SOURCE_QUALITY_MULTIPLIER,
|
|
5
8
|
} from "./constants";
|
|
6
9
|
import type { MemoryCandidate, RerankOptions } from "./types";
|
|
7
10
|
|
|
@@ -9,13 +12,16 @@ const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
|
|
9
12
|
const MS_PER_HOUR = 1000 * 60 * 60;
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
|
-
* Exponential decay based on age
|
|
13
|
-
*
|
|
15
|
+
* Exponential decay based on age and memory source.
|
|
16
|
+
* Source-aware: manual memories have no decay (Infinity half-life),
|
|
17
|
+
* file_index = 180d, task_completion = 14d, session_summary = 7d.
|
|
14
18
|
*/
|
|
15
|
-
export function recencyDecay(createdAt: string, now: Date): number {
|
|
19
|
+
export function recencyDecay(createdAt: string, now: Date, source?: AgentMemorySource): number {
|
|
20
|
+
const halfLife = source ? RECENCY_DECAY_HALF_LIFE[source] : RECENCY_DECAY_HALF_LIFE_DAYS;
|
|
21
|
+
if (!Number.isFinite(halfLife)) return 1.0;
|
|
16
22
|
const ageDays = (now.getTime() - new Date(createdAt).getTime()) / MS_PER_DAY;
|
|
17
23
|
if (ageDays <= 0) return 1.0;
|
|
18
|
-
return 2 ** (-ageDays /
|
|
24
|
+
return 2 ** (-ageDays / halfLife);
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
/**
|
|
@@ -31,6 +37,14 @@ export function accessBoost(accessedAt: string, accessCount: number, now: Date):
|
|
|
31
37
|
return boost;
|
|
32
38
|
}
|
|
33
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Source-quality multiplier. Manual memories get a 1.5× boost,
|
|
42
|
+
* session summaries get 0.5×. Unknown sources default to 1.0.
|
|
43
|
+
*/
|
|
44
|
+
export function sourceQuality(source: AgentMemorySource): number {
|
|
45
|
+
return SOURCE_QUALITY_MULTIPLIER[source] ?? 1.0;
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
/**
|
|
35
49
|
* Beta-Binomial usefulness factor for reranking.
|
|
36
50
|
*
|
|
@@ -56,33 +70,37 @@ export function usefulness(alpha: number, beta: number): number {
|
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
/**
|
|
59
|
-
* Final score combining similarity, recency decay, access boost,
|
|
60
|
-
* Beta-Binomial usefulness.
|
|
61
|
-
* MEMORY_DEMOTION_FLOOR=1.0, the usefulness factor is exactly 1.0 and this
|
|
62
|
-
* computation matches the pre-rater behaviour byte-for-byte.
|
|
63
|
-
*
|
|
64
|
-
* v2: optional edge-aware boost — see thoughts/taras/plans/2026-05-05-memory-rater-v1.5/root.md
|
|
73
|
+
* Final score combining similarity, recency decay, access boost,
|
|
74
|
+
* source quality, and Beta-Binomial usefulness.
|
|
65
75
|
*/
|
|
66
76
|
export function computeScore(candidate: MemoryCandidate, now: Date): number {
|
|
67
77
|
return (
|
|
68
78
|
candidate.similarity *
|
|
69
|
-
recencyDecay(candidate.createdAt, now) *
|
|
79
|
+
recencyDecay(candidate.createdAt, now, candidate.source) *
|
|
70
80
|
accessBoost(candidate.accessedAt, candidate.accessCount, now) *
|
|
81
|
+
sourceQuality(candidate.source) *
|
|
71
82
|
usefulness(candidate.alpha, candidate.beta)
|
|
72
83
|
);
|
|
73
84
|
}
|
|
74
85
|
|
|
75
86
|
/**
|
|
76
|
-
* Rerank candidates by combining similarity with recency
|
|
77
|
-
* Returns the top `limit` candidates sorted by
|
|
87
|
+
* Rerank candidates by combining similarity with recency, source quality,
|
|
88
|
+
* and access signals. Returns the top `limit` candidates sorted by composite
|
|
89
|
+
* score. Preserves raw similarity in `rawSimilarity` and sets `compositeScore`.
|
|
78
90
|
*/
|
|
79
91
|
export function rerank(candidates: MemoryCandidate[], options: RerankOptions): MemoryCandidate[] {
|
|
80
92
|
const { limit, now = new Date() } = options;
|
|
81
93
|
|
|
82
|
-
const scored = candidates.map((candidate) =>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
94
|
+
const scored = candidates.map((candidate) => {
|
|
95
|
+
const rawSimilarity = candidate.similarity;
|
|
96
|
+
const compositeScore = computeScore(candidate, now);
|
|
97
|
+
return {
|
|
98
|
+
...candidate,
|
|
99
|
+
rawSimilarity,
|
|
100
|
+
compositeScore,
|
|
101
|
+
similarity: compositeScore,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
86
104
|
|
|
87
105
|
scored.sort((a, b) => b.similarity - a.similarity);
|
|
88
106
|
return scored.slice(0, limit);
|
package/src/be/memory/types.ts
CHANGED
|
@@ -22,6 +22,10 @@ export interface MemoryStore {
|
|
|
22
22
|
peek(id: string): AgentMemory | null;
|
|
23
23
|
search(embedding: Float32Array, agentId: string, options: MemorySearchOptions): MemoryCandidate[];
|
|
24
24
|
list(agentId: string, options: MemoryListOptions): AgentMemory[];
|
|
25
|
+
isSourceProtected(source: AgentMemorySource): boolean;
|
|
26
|
+
listForCuration(
|
|
27
|
+
agentId?: string,
|
|
28
|
+
): { id: string; source: string; name: string; createdAt: string }[];
|
|
25
29
|
listForReembedding(options?: { agentId?: string }): { id: string; content: string }[];
|
|
26
30
|
delete(id: string): boolean;
|
|
27
31
|
deleteBySourcePath(sourcePath: string, agentId: string): number;
|
|
@@ -51,6 +55,10 @@ export interface MemoryInput {
|
|
|
51
55
|
|
|
52
56
|
export interface MemoryCandidate extends AgentMemory {
|
|
53
57
|
similarity: number;
|
|
58
|
+
/** Raw cosine similarity before reranking (preserved for diagnostics). */
|
|
59
|
+
rawSimilarity?: number;
|
|
60
|
+
/** Final composite score after reranking (recency × source × usefulness × access). */
|
|
61
|
+
compositeScore?: number;
|
|
54
62
|
accessCount: number;
|
|
55
63
|
expiresAt: string | null;
|
|
56
64
|
embeddingModel: string | null;
|