recallnest 1.4.1 → 2.2.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/.dockerignore +11 -0
- package/CLAUDE.md +7 -3
- package/Dockerfile +20 -0
- package/README.md +202 -130
- package/README_CN.md +263 -190
- package/ROADMAP.md +128 -81
- package/SKILL.md +250 -0
- package/assets/dashboard.png +0 -0
- package/assets/knowledge-graph.png +0 -0
- package/assets/screenshots/ui-full.png +0 -0
- package/assets/screenshots/ui-overview.png +0 -0
- package/assets/screenshots/ui-search.png +0 -0
- package/assets/ui/app.js +227 -3
- package/assets/ui/index.html +81 -11
- package/assets/ui/styles.css +178 -2
- package/bin/recallnest.mjs +62 -0
- package/bun.lock +337 -0
- package/daily-recall.sh +23 -0
- package/daily-reflections/2026-04-02.md +35 -0
- package/daily-reflections/2026-04-03.md +37 -0
- package/daily-reflections/2026-04-04.md +37 -0
- package/daily-reflections/2026-04-05.md +35 -0
- package/daily-reflections/2026-04-06.md +36 -0
- package/daily-reflections/2026-04-07.md +35 -0
- package/daily-reflections/2026-04-08.md +35 -0
- package/daily-reflections/2026-04-12.md +35 -0
- package/data/alias-map.example.json +22 -0
- package/docs/plans/2026-04-01-recall-boost.md +100 -0
- package/docs/superpowers/plans/2026-04-11-constructive-retrieval-emotion.md +1195 -0
- package/docs/superpowers/specs/2026-04-11-constructive-retrieval-emotion-design.md +398 -0
- package/glama.json +6 -0
- package/integrations/setup-all.sh +31 -0
- package/package.json +10 -3
- package/plan-philosophy-upgrades.md +363 -0
- package/plan-product-upgrades.md +155 -0
- package/plan.md +140 -0
- package/research-memory-evolution.md +440 -0
- package/research-rl-agent-memory.md +330 -0
- package/scripts/backfill-anchors.ts +130 -0
- package/scripts/diagnose-vector-search.ts +132 -0
- package/scripts/migrate-language.ts +75 -0
- package/scripts/verify-emotion-decay.ts +148 -0
- package/src/__tests__/activity-counter.test.ts +96 -0
- package/src/__tests__/admission-control.test.ts +206 -0
- package/src/__tests__/anchor-generator.test.ts +67 -0
- package/src/__tests__/babel-integration.test.ts +139 -0
- package/src/__tests__/capture-engine.test.ts +5 -3
- package/src/__tests__/cascade-forget.test.ts +1 -1
- package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
- package/src/__tests__/circuit-breaker.test.ts +99 -0
- package/src/__tests__/cluster-consolidation.test.ts +712 -0
- package/src/__tests__/confidence-metadata.test.ts +125 -0
- package/src/__tests__/confidence-tracker.test.ts +53 -0
- package/src/__tests__/consolidation-engine.test.ts +1 -1
- package/src/__tests__/constructive-retrieval.test.ts +509 -0
- package/src/__tests__/context-collapse-renderer.test.ts +214 -0
- package/src/__tests__/context-reconstructor.test.ts +65 -0
- package/src/__tests__/conversation-importer.test.ts +692 -0
- package/src/__tests__/data-checkup.test.ts +225 -0
- package/src/__tests__/decay-exemption.test.ts +84 -0
- package/src/__tests__/dedup-guard.test.ts +39 -0
- package/src/__tests__/distill-lock.test.ts +176 -0
- package/src/__tests__/dream-pipeline.test.ts +232 -0
- package/src/__tests__/emotion-decay.test.ts +46 -0
- package/src/__tests__/emotion-detector.test.ts +56 -0
- package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
- package/src/__tests__/emotion-retrieval.test.ts +56 -0
- package/src/__tests__/ethics-layer.test.ts +476 -0
- package/src/__tests__/feature-flags-integration.test.ts +54 -0
- package/src/__tests__/frequency-tracker.test.ts +113 -0
- package/src/__tests__/graph-export.test.ts +489 -0
- package/src/__tests__/interference-detector.test.ts +203 -0
- package/src/__tests__/mcp-efficiency.test.ts +288 -0
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +243 -0
- package/src/__tests__/memory-evolution.test.ts +271 -0
- package/src/__tests__/memory-lint.test.ts +271 -0
- package/src/__tests__/narrative-architecture.test.ts +456 -0
- package/src/__tests__/pending-review.test.ts +77 -0
- package/src/__tests__/post-retrieval-filter.test.ts +178 -0
- package/src/__tests__/prediction-engine.test.ts +349 -0
- package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
- package/src/__tests__/preference-slots.test.ts +99 -0
- package/src/__tests__/query-expander-alias.test.ts +89 -0
- package/src/__tests__/recall-governor.test.ts +227 -0
- package/src/__tests__/resume-context.test.ts +156 -1
- package/src/__tests__/salience-scoring.test.ts +106 -0
- package/src/__tests__/session-distiller.test.ts +611 -0
- package/src/__tests__/skill-engine.test.ts +546 -0
- package/src/__tests__/skill-promotion.test.ts +349 -0
- package/src/__tests__/supersede-chain.test.ts +154 -0
- package/src/__tests__/temporal-validity.test.ts +160 -0
- package/src/__tests__/topic-tag.test.ts +113 -0
- package/src/__tests__/trusted-memory.test.ts +828 -0
- package/src/__tests__/write-verifier.test.ts +122 -0
- package/src/access-tracker.ts +3 -2
- package/src/activity-counter.ts +109 -0
- package/src/adaptive-retrieval.ts +6 -5
- package/src/admission-control.ts +163 -0
- package/src/anchor-generator.ts +80 -0
- package/src/api-server.ts +10 -0
- package/src/audit-log.ts +122 -0
- package/src/auto-gc.ts +98 -29
- package/src/auto-recall.ts +33 -4
- package/src/capture-engine.ts +372 -23
- package/src/cascade-forget.ts +3 -2
- package/src/cli.ts +107 -5
- package/src/compat.ts +22 -0
- package/src/confidence-tracker.ts +210 -47
- package/src/conflict-store.ts +12 -3
- package/src/consolidation-engine.ts +296 -8
- package/src/context-collapse-renderer.ts +222 -0
- package/src/context-composer-stable-selection.ts +5 -1
- package/src/context-composer.ts +239 -4
- package/src/context-reconstructor.ts +404 -0
- package/src/conversation-importer.ts +441 -0
- package/src/data-checkup.ts +232 -0
- package/src/decay-engine.ts +84 -0
- package/src/distill-lock.ts +122 -0
- package/src/doctor.ts +8 -10
- package/src/dream-pipeline.ts +227 -0
- package/src/emotion-detector.ts +90 -0
- package/src/eval.ts +3 -2
- package/src/forget-engine.ts +269 -0
- package/src/frequency-tracker.ts +175 -0
- package/src/graph-export.ts +644 -0
- package/src/ingest.ts +36 -6
- package/src/interference-detector.ts +187 -0
- package/src/kg-extractor.ts +15 -24
- package/src/kg-store.ts +2 -1
- package/src/language-hook.ts +67 -0
- package/src/llm-client.ts +289 -6
- package/src/mcp-server.ts +707 -80
- package/src/memory-assets.ts +22 -5
- package/src/memory-evolution.ts +365 -0
- package/src/memory-lint.ts +429 -0
- package/src/memory-output.ts +69 -0
- package/src/memory-schema.ts +74 -2
- package/src/narrative-schema.ts +79 -0
- package/src/narrative-tagger.ts +164 -0
- package/src/pii-detector.ts +98 -0
- package/src/post-retrieval-filter.ts +76 -0
- package/src/prediction-engine.ts +253 -0
- package/src/preference-slots.ts +65 -2
- package/src/prospective-memory.ts +215 -0
- package/src/query-expander.ts +67 -0
- package/src/recall-governor.ts +183 -0
- package/src/retention-policy.ts +90 -0
- package/src/retriever.ts +828 -55
- package/src/rif.ts +34 -8
- package/src/runtime-config.ts +23 -8
- package/src/scope-inventory.ts +4 -3
- package/src/scope-policy.ts +4 -0
- package/src/session-distiller.ts +307 -0
- package/src/session-output.ts +40 -0
- package/src/session-schema.ts +37 -0
- package/src/session-store.ts +7 -2
- package/src/skill-engine.ts +208 -0
- package/src/skill-promotion.ts +293 -0
- package/src/skill-schema.ts +39 -0
- package/src/store.ts +96 -10
- package/src/topic-tag.ts +66 -0
- package/src/tracker.ts +3 -1
- package/src/ui-server.ts +80 -8
- package/src/workflow-observation-scope-review.ts +2 -1
- package/src/workflow-observation-store.ts +2 -1
- package/src/write-verifier.ts +126 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { filterByRelevance, type FilterConfig } from "../post-retrieval-filter.js";
|
|
4
|
+
import type { LLMClient } from "../llm-client.js";
|
|
5
|
+
import type { RetrievalResult } from "../retriever.js";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
function makeResult(id: string, text: string, score: number): RetrievalResult {
|
|
12
|
+
return {
|
|
13
|
+
entry: {
|
|
14
|
+
id,
|
|
15
|
+
text,
|
|
16
|
+
vector: [],
|
|
17
|
+
category: "events",
|
|
18
|
+
scope: "test",
|
|
19
|
+
importance: 0.5,
|
|
20
|
+
timestamp: Date.now(),
|
|
21
|
+
},
|
|
22
|
+
score,
|
|
23
|
+
sources: {},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeMockLLM(response: boolean[] | null, shouldThrow = false): LLMClient {
|
|
28
|
+
return {
|
|
29
|
+
chatJson: async () => {
|
|
30
|
+
if (shouldThrow) throw new Error("LLM failure");
|
|
31
|
+
return response;
|
|
32
|
+
},
|
|
33
|
+
} as unknown as LLMClient;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Tests
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
describe("filterByRelevance", () => {
|
|
41
|
+
it("returns empty array for empty results", async () => {
|
|
42
|
+
const llm = makeMockLLM([]);
|
|
43
|
+
const out = await filterByRelevance([], "some query", llm);
|
|
44
|
+
expect(out).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns original results when query is empty", async () => {
|
|
48
|
+
const results = [makeResult("a", "hello", 0.8)];
|
|
49
|
+
const llm = makeMockLLM([true]);
|
|
50
|
+
const out = await filterByRelevance(results, "", llm);
|
|
51
|
+
expect(out).toEqual(results);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns all when LLM marks all as relevant", async () => {
|
|
55
|
+
const results = [
|
|
56
|
+
makeResult("a", "TypeScript project setup", 0.9),
|
|
57
|
+
makeResult("b", "Docker deployment notes", 0.85),
|
|
58
|
+
];
|
|
59
|
+
const llm = makeMockLLM([true, true]);
|
|
60
|
+
const out = await filterByRelevance(results, "project setup", llm);
|
|
61
|
+
expect(out).toHaveLength(2);
|
|
62
|
+
expect(out[0].entry.id).toBe("a");
|
|
63
|
+
expect(out[1].entry.id).toBe("b");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("filters out items LLM marks as irrelevant", async () => {
|
|
67
|
+
const results = [
|
|
68
|
+
makeResult("a", "TypeScript project setup", 0.9),
|
|
69
|
+
makeResult("b", "Favorite pizza recipe", 0.85),
|
|
70
|
+
makeResult("c", "Docker deployment notes", 0.8),
|
|
71
|
+
];
|
|
72
|
+
const llm = makeMockLLM([true, false, true]);
|
|
73
|
+
const out = await filterByRelevance(results, "project deployment", llm);
|
|
74
|
+
expect(out).toHaveLength(2);
|
|
75
|
+
expect(out[0].entry.id).toBe("a");
|
|
76
|
+
expect(out[1].entry.id).toBe("c");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("drops items below minScoreForFilter without LLM call", async () => {
|
|
80
|
+
const results = [
|
|
81
|
+
makeResult("a", "High score item", 0.9),
|
|
82
|
+
makeResult("b", "Low score item", 0.3),
|
|
83
|
+
makeResult("c", "Very low score", 0.1),
|
|
84
|
+
];
|
|
85
|
+
// LLM only sees 1 item (the one above threshold)
|
|
86
|
+
const llm = makeMockLLM([true]);
|
|
87
|
+
const out = await filterByRelevance(results, "test query", llm);
|
|
88
|
+
expect(out).toHaveLength(1);
|
|
89
|
+
expect(out[0].entry.id).toBe("a");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns all candidates when all are below minScoreForFilter", async () => {
|
|
93
|
+
const results = [
|
|
94
|
+
makeResult("a", "Low score A", 0.3),
|
|
95
|
+
makeResult("b", "Low score B", 0.2),
|
|
96
|
+
];
|
|
97
|
+
const llm = makeMockLLM([]);
|
|
98
|
+
const out = await filterByRelevance(results, "test query", llm);
|
|
99
|
+
expect(out).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("truncates to maxItems", async () => {
|
|
103
|
+
const results = Array.from({ length: 15 }, (_, i) =>
|
|
104
|
+
makeResult(`item-${i}`, `Memory item ${i}`, 0.9 - i * 0.01),
|
|
105
|
+
);
|
|
106
|
+
// LLM receives only 5 items, marks all as relevant
|
|
107
|
+
const cfg: Partial<FilterConfig> = { maxItems: 5 };
|
|
108
|
+
const llm = makeMockLLM([true, true, true, true, true]);
|
|
109
|
+
const out = await filterByRelevance(results, "test query", llm, cfg);
|
|
110
|
+
expect(out).toHaveLength(5);
|
|
111
|
+
expect(out[0].entry.id).toBe("item-0");
|
|
112
|
+
expect(out[4].entry.id).toBe("item-4");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns candidates on LLM failure (graceful fallback)", async () => {
|
|
116
|
+
const results = [
|
|
117
|
+
makeResult("a", "Important memory", 0.9),
|
|
118
|
+
makeResult("b", "Another memory", 0.85),
|
|
119
|
+
];
|
|
120
|
+
const llm = makeMockLLM(null, true);
|
|
121
|
+
const out = await filterByRelevance(results, "test query", llm);
|
|
122
|
+
// Should return all candidates, not lose data
|
|
123
|
+
expect(out).toHaveLength(2);
|
|
124
|
+
expect(out[0].entry.id).toBe("a");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns candidates when LLM returns non-array", async () => {
|
|
128
|
+
const results = [
|
|
129
|
+
makeResult("a", "Important memory", 0.9),
|
|
130
|
+
];
|
|
131
|
+
const llm = makeMockLLM(null);
|
|
132
|
+
const out = await filterByRelevance(results, "test query", llm);
|
|
133
|
+
expect(out).toHaveLength(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("uses l0_abstract from metadata when available", async () => {
|
|
137
|
+
const result = makeResult("a", "Very long raw text that should not be used", 0.9);
|
|
138
|
+
result.entry.metadata = JSON.stringify({ l0_abstract: "Short summary" });
|
|
139
|
+
const calls: string[] = [];
|
|
140
|
+
const llm = {
|
|
141
|
+
chatJson: async (_sys: string, user: string) => {
|
|
142
|
+
calls.push(user);
|
|
143
|
+
return [true];
|
|
144
|
+
},
|
|
145
|
+
} as unknown as LLMClient;
|
|
146
|
+
|
|
147
|
+
await filterByRelevance([result], "test", llm);
|
|
148
|
+
expect(calls[0]).toContain("Short summary");
|
|
149
|
+
expect(calls[0]).not.toContain("Very long raw text");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("handles custom minScoreForFilter", async () => {
|
|
153
|
+
const results = [
|
|
154
|
+
makeResult("a", "High", 0.9),
|
|
155
|
+
makeResult("b", "Medium", 0.6),
|
|
156
|
+
makeResult("c", "Low", 0.4),
|
|
157
|
+
];
|
|
158
|
+
const cfg: Partial<FilterConfig> = { minScoreForFilter: 0.65 };
|
|
159
|
+
const llm = makeMockLLM([true]);
|
|
160
|
+
const out = await filterByRelevance(results, "test", llm, cfg);
|
|
161
|
+
expect(out).toHaveLength(1);
|
|
162
|
+
expect(out[0].entry.id).toBe("a");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("handles shorter LLM verdict array than candidates", async () => {
|
|
166
|
+
const results = [
|
|
167
|
+
makeResult("a", "Memory A", 0.9),
|
|
168
|
+
makeResult("b", "Memory B", 0.85),
|
|
169
|
+
makeResult("c", "Memory C", 0.8),
|
|
170
|
+
];
|
|
171
|
+
// LLM returns fewer verdicts than items
|
|
172
|
+
const llm = makeMockLLM([true, false]);
|
|
173
|
+
const out = await filterByRelevance(results, "test", llm);
|
|
174
|
+
// Only first item passes (index 0 = true, index 1 = false, index 2 = out of bounds)
|
|
175
|
+
expect(out).toHaveLength(1);
|
|
176
|
+
expect(out[0].entry.id).toBe("a");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for HP-predictive Phase 5: Prediction Engine
|
|
3
|
+
*
|
|
4
|
+
* Validates:
|
|
5
|
+
* 1. collectSignals() — each signal type is correctly extracted
|
|
6
|
+
* 2. scorePredictions() — scoring, filtering by threshold, ranking
|
|
7
|
+
* 3. Edge cases — empty inputs, boundary conditions
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
collectSignals,
|
|
12
|
+
scorePredictions,
|
|
13
|
+
_testConstants,
|
|
14
|
+
type PredictionContext,
|
|
15
|
+
type PredictionSignal,
|
|
16
|
+
} from "../prediction-engine.js";
|
|
17
|
+
import type { SessionCheckpointRecord } from "../session-schema.js";
|
|
18
|
+
import type { WorkflowObservationRecord } from "../workflow-observation-schema.js";
|
|
19
|
+
|
|
20
|
+
const { CONFIDENCE_THRESHOLD, STALE_OPEN_LOOP_HOURS, STALE_MEMORY_DAYS, MAX_PREDICTIONS, SIGNAL_WEIGHTS } = _testConstants;
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Helpers
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function makeCheckpoint(overrides: Partial<SessionCheckpointRecord> & { openLoops?: string[]; updatedAt: string }): SessionCheckpointRecord {
|
|
27
|
+
return {
|
|
28
|
+
checkpointId: `cp-${Math.random().toString(36).slice(2, 8)}`,
|
|
29
|
+
sessionId: "test-session",
|
|
30
|
+
resolvedScope: "test",
|
|
31
|
+
scope: "test",
|
|
32
|
+
summary: "test checkpoint",
|
|
33
|
+
task: "test task",
|
|
34
|
+
decisions: [],
|
|
35
|
+
openLoops: overrides.openLoops ?? [],
|
|
36
|
+
nextActions: [],
|
|
37
|
+
entities: [],
|
|
38
|
+
files: [],
|
|
39
|
+
updatedAt: overrides.updatedAt,
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeWorkflowObservation(overrides: Partial<WorkflowObservationRecord>): WorkflowObservationRecord {
|
|
45
|
+
return {
|
|
46
|
+
observationId: `obs-${Math.random().toString(36).slice(2, 8)}`,
|
|
47
|
+
workflowId: "test-workflow",
|
|
48
|
+
outcome: "corrected",
|
|
49
|
+
summary: "Fixed a workflow issue",
|
|
50
|
+
scope: "test",
|
|
51
|
+
resolvedScope: "test",
|
|
52
|
+
source: "manual",
|
|
53
|
+
tags: [],
|
|
54
|
+
tools: [],
|
|
55
|
+
recordedAt: new Date().toISOString(),
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function emptyContext(overrides?: Partial<PredictionContext>): PredictionContext {
|
|
61
|
+
return {
|
|
62
|
+
checkpoints: [],
|
|
63
|
+
workflowObservations: [],
|
|
64
|
+
frequentMemories: [],
|
|
65
|
+
uncoveredTopics: [],
|
|
66
|
+
now: new Date("2026-04-11T12:00:00Z"),
|
|
67
|
+
...overrides,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// collectSignals
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
describe("collectSignals", () => {
|
|
76
|
+
describe("stale open loop signals", () => {
|
|
77
|
+
it("detects stale open loops from checkpoints older than 24h", () => {
|
|
78
|
+
const now = new Date("2026-04-11T12:00:00Z");
|
|
79
|
+
const staleCheckpoint = makeCheckpoint({
|
|
80
|
+
openLoops: ["Fix CI pipeline", "Review PR #42"],
|
|
81
|
+
updatedAt: new Date(now.getTime() - 48 * 60 * 60 * 1000).toISOString(), // 48h ago
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const signals = collectSignals(emptyContext({
|
|
85
|
+
checkpoints: [staleCheckpoint],
|
|
86
|
+
now,
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
const openLoopSignals = signals.filter(s => s.type === "stale_open_loop");
|
|
90
|
+
expect(openLoopSignals.length).toBe(2);
|
|
91
|
+
expect(openLoopSignals[0].trigger).toBe("Fix CI pipeline");
|
|
92
|
+
expect(openLoopSignals[0].weight).toBeGreaterThan(0.6);
|
|
93
|
+
expect(openLoopSignals[0].evidence.length).toBeGreaterThan(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("ignores fresh open loops (< 24h)", () => {
|
|
97
|
+
const now = new Date("2026-04-11T12:00:00Z");
|
|
98
|
+
const freshCheckpoint = makeCheckpoint({
|
|
99
|
+
openLoops: ["In-progress task"],
|
|
100
|
+
updatedAt: new Date(now.getTime() - 12 * 60 * 60 * 1000).toISOString(), // 12h ago
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const signals = collectSignals(emptyContext({
|
|
104
|
+
checkpoints: [freshCheckpoint],
|
|
105
|
+
now,
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
expect(signals.filter(s => s.type === "stale_open_loop").length).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("deduplicates same open loop across checkpoints", () => {
|
|
112
|
+
const now = new Date("2026-04-11T12:00:00Z");
|
|
113
|
+
const cp1 = makeCheckpoint({
|
|
114
|
+
openLoops: ["Fix CI pipeline"],
|
|
115
|
+
updatedAt: new Date(now.getTime() - 48 * 60 * 60 * 1000).toISOString(),
|
|
116
|
+
});
|
|
117
|
+
const cp2 = makeCheckpoint({
|
|
118
|
+
openLoops: ["fix ci pipeline"], // same but different case
|
|
119
|
+
updatedAt: new Date(now.getTime() - 72 * 60 * 60 * 1000).toISOString(),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const signals = collectSignals(emptyContext({
|
|
123
|
+
checkpoints: [cp1, cp2],
|
|
124
|
+
now,
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
expect(signals.filter(s => s.type === "stale_open_loop").length).toBe(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("weights increase with staleness", () => {
|
|
131
|
+
const now = new Date("2026-04-11T12:00:00Z");
|
|
132
|
+
const cp24h = makeCheckpoint({
|
|
133
|
+
openLoops: ["Task A"],
|
|
134
|
+
updatedAt: new Date(now.getTime() - 25 * 60 * 60 * 1000).toISOString(),
|
|
135
|
+
});
|
|
136
|
+
const cp72h = makeCheckpoint({
|
|
137
|
+
openLoops: ["Task B"],
|
|
138
|
+
updatedAt: new Date(now.getTime() - 72 * 60 * 60 * 1000).toISOString(),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const signals = collectSignals(emptyContext({
|
|
142
|
+
checkpoints: [cp24h, cp72h],
|
|
143
|
+
now,
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
const signalA = signals.find(s => s.trigger === "Task A")!;
|
|
147
|
+
const signalB = signals.find(s => s.trigger === "Task B")!;
|
|
148
|
+
expect(signalB.weight).toBeGreaterThan(signalA.weight);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("stale high-frequency memory signals", () => {
|
|
153
|
+
it("detects memories accessed frequently but not recently", () => {
|
|
154
|
+
const now = new Date("2026-04-11T12:00:00Z");
|
|
155
|
+
const signals = collectSignals(emptyContext({
|
|
156
|
+
frequentMemories: [{
|
|
157
|
+
text: "Auth migration plan",
|
|
158
|
+
topicTag: "auth",
|
|
159
|
+
lastAccessedAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days ago
|
|
160
|
+
accessCount: 5,
|
|
161
|
+
}],
|
|
162
|
+
now,
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
const staleSignals = signals.filter(s => s.type === "stale_high_frequency");
|
|
166
|
+
expect(staleSignals.length).toBe(1);
|
|
167
|
+
expect(staleSignals[0].trigger).toBe("auth");
|
|
168
|
+
expect(staleSignals[0].evidence).toContain("Accessed 5 times but not in 30 days");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("ignores recently accessed memories", () => {
|
|
172
|
+
const now = new Date("2026-04-11T12:00:00Z");
|
|
173
|
+
const signals = collectSignals(emptyContext({
|
|
174
|
+
frequentMemories: [{
|
|
175
|
+
text: "Recent memory",
|
|
176
|
+
lastAccessedAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 days ago
|
|
177
|
+
accessCount: 10,
|
|
178
|
+
}],
|
|
179
|
+
now,
|
|
180
|
+
}));
|
|
181
|
+
|
|
182
|
+
expect(signals.filter(s => s.type === "stale_high_frequency").length).toBe(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("ignores infrequently accessed memories (< 3 accesses)", () => {
|
|
186
|
+
const now = new Date("2026-04-11T12:00:00Z");
|
|
187
|
+
const signals = collectSignals(emptyContext({
|
|
188
|
+
frequentMemories: [{
|
|
189
|
+
text: "Rarely accessed",
|
|
190
|
+
lastAccessedAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
191
|
+
accessCount: 2,
|
|
192
|
+
}],
|
|
193
|
+
now,
|
|
194
|
+
}));
|
|
195
|
+
|
|
196
|
+
expect(signals.filter(s => s.type === "stale_high_frequency").length).toBe(0);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("workflow issue signals", () => {
|
|
201
|
+
it("detects corrected/missed workflow observations", () => {
|
|
202
|
+
const observations = [
|
|
203
|
+
makeWorkflowObservation({ workflowId: "deploy", outcome: "corrected", summary: "Forgot to run tests" }),
|
|
204
|
+
makeWorkflowObservation({ workflowId: "deploy", outcome: "missed", summary: "Missed the changelog update" }),
|
|
205
|
+
makeWorkflowObservation({ workflowId: "deploy", outcome: "success", summary: "All good" }),
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
const signals = collectSignals(emptyContext({ workflowObservations: observations }));
|
|
209
|
+
const issueSignals = signals.filter(s => s.type === "workflow_issue");
|
|
210
|
+
expect(issueSignals.length).toBe(1);
|
|
211
|
+
expect(issueSignals[0].trigger).toBe("deploy");
|
|
212
|
+
expect(issueSignals[0].evidence[0]).toContain("2 corrected/missed");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("ignores workflows with only successes", () => {
|
|
216
|
+
const observations = [
|
|
217
|
+
makeWorkflowObservation({ workflowId: "deploy", outcome: "success" }),
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
const signals = collectSignals(emptyContext({ workflowObservations: observations }));
|
|
221
|
+
expect(signals.filter(s => s.type === "workflow_issue").length).toBe(0);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("uncovered topic signals", () => {
|
|
226
|
+
it("creates signals for uncovered topics", () => {
|
|
227
|
+
const signals = collectSignals(emptyContext({
|
|
228
|
+
uncoveredTopics: ["kubernetes", "migration"],
|
|
229
|
+
}));
|
|
230
|
+
|
|
231
|
+
const topicSignals = signals.filter(s => s.type === "uncovered_topic");
|
|
232
|
+
expect(topicSignals.length).toBe(2);
|
|
233
|
+
expect(topicSignals[0].trigger).toBe("kubernetes");
|
|
234
|
+
expect(topicSignals[0].weight).toBe(0.65);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("empty inputs", () => {
|
|
239
|
+
it("returns empty array for empty context", () => {
|
|
240
|
+
const signals = collectSignals(emptyContext());
|
|
241
|
+
expect(signals).toEqual([]);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// scorePredictions
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
describe("scorePredictions", () => {
|
|
251
|
+
it("scores signals and filters by confidence threshold", () => {
|
|
252
|
+
const signals: PredictionSignal[] = [
|
|
253
|
+
{
|
|
254
|
+
type: "stale_open_loop",
|
|
255
|
+
trigger: "Fix CI",
|
|
256
|
+
action: "Unresolved: Fix CI",
|
|
257
|
+
weight: 0.9,
|
|
258
|
+
evidence: ["48h old"],
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
type: "uncovered_topic",
|
|
262
|
+
trigger: "obscure",
|
|
263
|
+
action: "No memories for obscure",
|
|
264
|
+
weight: 0.5, // Below threshold after scoring
|
|
265
|
+
evidence: ["No coverage"],
|
|
266
|
+
},
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
const predictions = scorePredictions(signals);
|
|
270
|
+
|
|
271
|
+
// stale_open_loop: 0.9 * 0.9 = 0.81 (above 0.6)
|
|
272
|
+
// uncovered_topic: 0.5 * 0.65 = 0.325 (below 0.6)
|
|
273
|
+
expect(predictions.length).toBe(1);
|
|
274
|
+
expect(predictions[0].trigger).toBe("Fix CI");
|
|
275
|
+
expect(predictions[0].confidence).toBe(0.81);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("sorts by confidence descending", () => {
|
|
279
|
+
const signals: PredictionSignal[] = [
|
|
280
|
+
{ type: "uncovered_topic", trigger: "low", action: "low", weight: 1.0, evidence: [] },
|
|
281
|
+
{ type: "stale_open_loop", trigger: "high", action: "high", weight: 1.0, evidence: [] },
|
|
282
|
+
{ type: "workflow_issue", trigger: "mid", action: "mid", weight: 1.0, evidence: [] },
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
const predictions = scorePredictions(signals);
|
|
286
|
+
// stale_open_loop: 1.0 * 0.9 = 0.9
|
|
287
|
+
// workflow_issue: 1.0 * 0.85 = 0.85
|
|
288
|
+
// uncovered_topic: 1.0 * 0.65 = 0.65
|
|
289
|
+
expect(predictions.length).toBe(3);
|
|
290
|
+
expect(predictions[0].trigger).toBe("high");
|
|
291
|
+
expect(predictions[1].trigger).toBe("mid");
|
|
292
|
+
expect(predictions[2].trigger).toBe("low");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("limits to MAX_PREDICTIONS", () => {
|
|
296
|
+
const signals: PredictionSignal[] = Array.from({ length: 10 }, (_, i) => ({
|
|
297
|
+
type: "stale_open_loop" as const,
|
|
298
|
+
trigger: `task-${i}`,
|
|
299
|
+
action: `action-${i}`,
|
|
300
|
+
weight: 1.0,
|
|
301
|
+
evidence: [],
|
|
302
|
+
}));
|
|
303
|
+
|
|
304
|
+
const predictions = scorePredictions(signals);
|
|
305
|
+
expect(predictions.length).toBe(MAX_PREDICTIONS);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("returns empty for all below threshold", () => {
|
|
309
|
+
const signals: PredictionSignal[] = [
|
|
310
|
+
{ type: "uncovered_topic", trigger: "x", action: "x", weight: 0.3, evidence: [] },
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
const predictions = scorePredictions(signals);
|
|
314
|
+
expect(predictions.length).toBe(0);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("returns empty for empty signals", () => {
|
|
318
|
+
expect(scorePredictions([])).toEqual([]);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Constants sanity
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
describe("prediction engine constants", () => {
|
|
327
|
+
it("confidence threshold is 0.6", () => {
|
|
328
|
+
expect(CONFIDENCE_THRESHOLD).toBe(0.6);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("stale open loop hours is 24", () => {
|
|
332
|
+
expect(STALE_OPEN_LOOP_HOURS).toBe(24);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("stale memory days is 14", () => {
|
|
336
|
+
expect(STALE_MEMORY_DAYS).toBe(14);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("max predictions is 5", () => {
|
|
340
|
+
expect(MAX_PREDICTIONS).toBe(5);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("signal weights are all between 0 and 1", () => {
|
|
344
|
+
for (const [, weight] of Object.entries(SIGNAL_WEIGHTS)) {
|
|
345
|
+
expect(weight).toBeGreaterThanOrEqual(0);
|
|
346
|
+
expect(weight).toBeLessThanOrEqual(1);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
});
|