aiden-runtime 4.1.0 → 4.1.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.
Files changed (61) hide show
  1. package/README.md +89 -33
  2. package/dist/cli/v4/aidenCLI.js +162 -11
  3. package/dist/cli/v4/callbacks.js +5 -2
  4. package/dist/cli/v4/chatSession.js +525 -15
  5. package/dist/cli/v4/commands/auth.js +6 -3
  6. package/dist/cli/v4/commands/help.js +4 -0
  7. package/dist/cli/v4/commands/index.js +10 -1
  8. package/dist/cli/v4/commands/reloadSoul.js +37 -0
  9. package/dist/cli/v4/commands/update.js +102 -0
  10. package/dist/cli/v4/defaultSoul.js +68 -2
  11. package/dist/cli/v4/display.js +28 -10
  12. package/dist/cli/v4/doctor.js +173 -1
  13. package/dist/cli/v4/doctorLiveness.js +384 -0
  14. package/dist/cli/v4/promotionPrompt.js +202 -0
  15. package/dist/cli/v4/providerBootSelector.js +144 -0
  16. package/dist/cli/v4/sessionSummaryGate.js +66 -0
  17. package/dist/cli/v4/toolPreview.js +139 -0
  18. package/dist/core/v4/aidenAgent.js +91 -29
  19. package/dist/core/v4/capabilities.js +89 -0
  20. package/dist/core/v4/contextCompressor.js +25 -8
  21. package/dist/core/v4/distillationIndex.js +167 -0
  22. package/dist/core/v4/distillationStore.js +98 -0
  23. package/dist/core/v4/logger/logger.js +40 -9
  24. package/dist/core/v4/promotionCandidates.js +234 -0
  25. package/dist/core/v4/promptBuilder.js +145 -1
  26. package/dist/core/v4/sessionDistiller.js +405 -0
  27. package/dist/core/v4/skillMining/extractorPrompt.js +28 -21
  28. package/dist/core/v4/skillMining/proposalBuilder.js +3 -2
  29. package/dist/core/v4/skillMining/skillMiner.js +43 -6
  30. package/dist/core/v4/skillOutcomeTracker.js +323 -0
  31. package/dist/core/v4/subsystemHealth.js +143 -0
  32. package/dist/core/v4/update/executeInstall.js +233 -0
  33. package/dist/core/version.js +1 -1
  34. package/dist/moat/dangerousPatterns.js +1 -1
  35. package/dist/moat/memoryGuard.js +111 -0
  36. package/dist/moat/skillTeacher.js +14 -5
  37. package/dist/providers/v4/chatCompletionsAdapter.js +9 -0
  38. package/dist/providers/v4/codexResponsesAdapter.js +7 -2
  39. package/dist/providers/v4/errors.js +67 -1
  40. package/dist/providers/v4/modelDefaults.js +65 -0
  41. package/dist/providers/v4/ollamaPromptToolsAdapter.js +9 -2
  42. package/dist/providers/v4/registry.js +9 -2
  43. package/dist/providers/v4/runtimeResolver.js +6 -0
  44. package/dist/tools/v4/index.js +57 -1
  45. package/dist/tools/v4/memory/memoryRemove.js +57 -2
  46. package/dist/tools/v4/memory/sessionSummary.js +151 -0
  47. package/dist/tools/v4/sessions/recallSession.js +163 -0
  48. package/dist/tools/v4/sessions/sessionSearch.js +5 -1
  49. package/dist/tools/v4/subagent/subagentFanout.js +24 -0
  50. package/dist/tools/v4/system/_psHelpers.js +55 -0
  51. package/dist/tools/v4/system/aidenSelfUpdate.js +162 -0
  52. package/dist/tools/v4/system/appClose.js +79 -0
  53. package/dist/tools/v4/system/appLaunch.js +92 -0
  54. package/dist/tools/v4/system/clipboardRead.js +54 -0
  55. package/dist/tools/v4/system/clipboardWrite.js +84 -0
  56. package/dist/tools/v4/system/mediaKey.js +78 -0
  57. package/dist/tools/v4/system/osProcessList.js +99 -0
  58. package/dist/tools/v4/system/screenshot.js +106 -0
  59. package/dist/tools/v4/system/volumeSet.js +157 -0
  60. package/package.json +4 -1
  61. package/skills/system_control.md +135 -69
@@ -0,0 +1,167 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/distillationIndex.ts — Phase v4.1.2-memory-C.
10
+ *
11
+ * Pure ranking + filtering over an in-memory list of
12
+ * `SessionDistillation` records. The tool handler
13
+ * (`tools/v4/sessions/recallSession.ts`) reads JSON files off disk
14
+ * and passes them in here; this module has no I/O.
15
+ *
16
+ * Ranking rules (per slice's Q3):
17
+ * - No query → recency-only: sort by `ended_at` desc, take top N.
18
+ * - Query present → score by total keyword-substring matches across:
19
+ * keywords[], bullets[], decisions[], open_items[],
20
+ * tools_used[].name
21
+ * Recency breaks ties.
22
+ * - `days` window filters out anything with
23
+ * `now - ended_at > days * 86_400_000` BEFORE scoring.
24
+ *
25
+ * No hybrid weighting, no LLM call, no embeddings — those are Phase E
26
+ * concerns. Today's ranking stays debuggable: the user can read why a
27
+ * result ranked where it did from `relevance` + the match field
28
+ * listed in each candidate.
29
+ *
30
+ * Index strategy: scan-all. Expected file count is <1000 per user;
31
+ * the tool handler reads every file from disk per query (sub-100ms at
32
+ * that scale). When real usage shows query latency >500ms, migrate
33
+ * directly to SQLite FTS5 — skip a JSON-index intermediate step.
34
+ */
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.rankDistillations = rankDistillations;
37
+ exports.scoreMatch = scoreMatch;
38
+ // ── Constants ─────────────────────────────────────────────────────────────
39
+ const DEFAULT_LIMIT = 5;
40
+ const MAX_LIMIT = 25;
41
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
42
+ // ── Pure ranking ──────────────────────────────────────────────────────────
43
+ /**
44
+ * Rank + filter distillations against a query. Pure: no I/O, no
45
+ * side effects, no clock-reads other than `nowMs` for the recency
46
+ * window (injectable for deterministic tests).
47
+ *
48
+ * @param dists Every distillation read from disk. Scan-all per slice's
49
+ * index strategy — caller owns the I/O.
50
+ * @param query User-supplied recall query.
51
+ * @param nowMs Reference time for the days window. Defaults to
52
+ * `Date.now()`; tests inject a fixed value.
53
+ */
54
+ function rankDistillations(dists, query = {}, nowMs = Date.now()) {
55
+ const scanned = dists.length;
56
+ const limit = clampLimit(query.limit);
57
+ const keyword = (query.query ?? '').trim().toLowerCase();
58
+ // 1. Days window filter.
59
+ let pool;
60
+ if (typeof query.days === 'number' && query.days > 0) {
61
+ const cutoff = nowMs - query.days * ONE_DAY_MS;
62
+ pool = dists.filter((d) => {
63
+ const t = Date.parse(d.ended_at);
64
+ return Number.isFinite(t) && t >= cutoff;
65
+ });
66
+ }
67
+ else {
68
+ pool = [...dists];
69
+ }
70
+ // 2. Score + filter.
71
+ let scored;
72
+ let relevance;
73
+ if (keyword.length === 0) {
74
+ // Recency-only: every survivor passes; score by inverse end-time
75
+ // so the sort below is identical to "newest first".
76
+ scored = pool.map((d) => ({
77
+ d,
78
+ score: 0,
79
+ ended: safeEndedMs(d),
80
+ }));
81
+ relevance = 'recency';
82
+ }
83
+ else {
84
+ scored = [];
85
+ for (const d of pool) {
86
+ const score = scoreMatch(d, keyword);
87
+ if (score > 0)
88
+ scored.push({ d, score, ended: safeEndedMs(d) });
89
+ }
90
+ relevance = 'keyword';
91
+ }
92
+ // 3. Sort. Keyword path: score desc, recency tiebreak. Recency
93
+ // path: ended desc.
94
+ scored.sort((a, b) => {
95
+ if (keyword.length > 0 && a.score !== b.score)
96
+ return b.score - a.score;
97
+ return b.ended - a.ended;
98
+ });
99
+ const total_found = scored.length;
100
+ const matches = scored
101
+ .slice(0, limit)
102
+ .map(({ d }) => toRecallMatch(d, relevance, query.include_full === true));
103
+ return { matches, total_found, scanned };
104
+ }
105
+ // ── Helpers ───────────────────────────────────────────────────────────────
106
+ function clampLimit(raw) {
107
+ if (typeof raw !== 'number' || !Number.isFinite(raw))
108
+ return DEFAULT_LIMIT;
109
+ return Math.max(1, Math.min(MAX_LIMIT, Math.floor(raw)));
110
+ }
111
+ function safeEndedMs(d) {
112
+ const t = Date.parse(d.ended_at);
113
+ return Number.isFinite(t) ? t : 0;
114
+ }
115
+ /**
116
+ * Compute the keyword-match score for one distillation. Counts every
117
+ * field-string that contains the keyword as a substring (case-fold).
118
+ * Each hit = 1 point — simple and debuggable. Multi-occurrence inside
119
+ * one string still counts as 1 hit (we score field presence, not
120
+ * frequency).
121
+ *
122
+ * Fields scanned (per slice's explicit list):
123
+ * - keywords[]
124
+ * - bullets[]
125
+ * - decisions[]
126
+ * - open_items[]
127
+ * - tools_used[].name
128
+ */
129
+ function scoreMatch(d, keyword) {
130
+ let score = 0;
131
+ for (const k of d.keywords)
132
+ if (k.toLowerCase().includes(keyword))
133
+ score += 1;
134
+ for (const b of d.bullets)
135
+ if (b.toLowerCase().includes(keyword))
136
+ score += 1;
137
+ for (const dc of d.decisions)
138
+ if (dc.toLowerCase().includes(keyword))
139
+ score += 1;
140
+ for (const o of d.open_items)
141
+ if (o.toLowerCase().includes(keyword))
142
+ score += 1;
143
+ for (const t of d.tools_used)
144
+ if (t.name.toLowerCase().includes(keyword))
145
+ score += 1;
146
+ return score;
147
+ }
148
+ function toRecallMatch(d, relevance, includeFull) {
149
+ const out = {
150
+ session_id: d.session_id,
151
+ started_at: d.started_at,
152
+ ended_at: d.ended_at,
153
+ exit_path: d.exit_path,
154
+ relevance,
155
+ bullets: d.bullets,
156
+ decisions: d.decisions,
157
+ open_items: d.open_items,
158
+ files_touched: d.files_touched,
159
+ };
160
+ if (includeFull) {
161
+ out.tools_used = d.tools_used;
162
+ out.keywords = d.keywords;
163
+ }
164
+ if (d.partial)
165
+ out.partial = true;
166
+ return out;
167
+ }
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/distillationStore.ts — Phase v4.1.2-memory-AB.
10
+ *
11
+ * On-disk persistence for `SessionDistillation` objects. One JSON file
12
+ * per session at `<dir>/<session_id>.json`. Atomic writes via tempfile
13
+ * + rename (same pattern as slice4's SkillOutcomeTracker).
14
+ *
15
+ * Disk layout intentionally flat — Phase C's retrieval surface will
16
+ * scan this directory and index the results. No subdirectories,
17
+ * no sharding; sessions are bounded enough that a single dir works
18
+ * (the typical user produces tens to low-hundreds of sessions/year).
19
+ *
20
+ * Failures are caught + surfaced via a slice3 SubsystemHealthTracker
21
+ * when one is wired; the write resolves anyway so the caller (chat
22
+ * session exit path) is never stuck on a disk failure.
23
+ */
24
+ var __importDefault = (this && this.__importDefault) || function (mod) {
25
+ return (mod && mod.__esModule) ? mod : { "default": mod };
26
+ };
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.writeDistillation = writeDistillation;
29
+ exports.readDistillation = readDistillation;
30
+ exports.listDistillationIds = listDistillationIds;
31
+ const node_fs_1 = require("node:fs");
32
+ const node_path_1 = __importDefault(require("node:path"));
33
+ /**
34
+ * Write one distillation file under `dir/<session_id>.json`. Atomic:
35
+ * writes to `<file>.tmp` then renames. Returns the final path on
36
+ * success, throws when the rename can't complete.
37
+ *
38
+ * @param healthTracker Optional — if provided, success/failure is
39
+ * recorded for `aiden doctor` surfacing.
40
+ */
41
+ async function writeDistillation(dir, dist, healthTracker) {
42
+ const file = node_path_1.default.join(dir, `${dist.session_id}.json`);
43
+ const tmp = `${file}.tmp`;
44
+ try {
45
+ await node_fs_1.promises.mkdir(dir, { recursive: true });
46
+ await node_fs_1.promises.writeFile(tmp, JSON.stringify(dist, null, 2) + '\n', 'utf-8');
47
+ await node_fs_1.promises.rename(tmp, file);
48
+ healthTracker?.recordSuccess();
49
+ return file;
50
+ }
51
+ catch (err) {
52
+ healthTracker?.recordFailure(err);
53
+ // Clean up any orphaned tempfile — best-effort.
54
+ try {
55
+ await node_fs_1.promises.unlink(tmp);
56
+ }
57
+ catch { /* ignore */ }
58
+ throw err;
59
+ }
60
+ }
61
+ /**
62
+ * Read one distillation by session id. Returns `null` when the file
63
+ * doesn't exist; throws on parse / permission errors.
64
+ *
65
+ * Caller is responsible for validating `schema_version` if it cares
66
+ * about future migrations. No version coercion in v1.
67
+ */
68
+ async function readDistillation(dir, sessionId) {
69
+ const file = node_path_1.default.join(dir, `${sessionId}.json`);
70
+ try {
71
+ const raw = await node_fs_1.promises.readFile(file, 'utf-8');
72
+ return JSON.parse(raw);
73
+ }
74
+ catch (err) {
75
+ if (err.code === 'ENOENT')
76
+ return null;
77
+ throw err;
78
+ }
79
+ }
80
+ /**
81
+ * List session ids that have a distillation on disk. Returns the
82
+ * basenames (without `.json` extension), sorted lexicographically.
83
+ * Used by Phase C's retrieval index.
84
+ */
85
+ async function listDistillationIds(dir) {
86
+ try {
87
+ const entries = await node_fs_1.promises.readdir(dir);
88
+ return entries
89
+ .filter((e) => e.endsWith('.json') && !e.endsWith('.tmp.json'))
90
+ .map((e) => e.slice(0, -'.json'.length))
91
+ .sort();
92
+ }
93
+ catch (err) {
94
+ if (err.code === 'ENOENT')
95
+ return [];
96
+ throw err;
97
+ }
98
+ }
@@ -33,11 +33,6 @@ exports.LOG_LEVEL_ORDER = {
33
33
  warn: 30,
34
34
  error: 40,
35
35
  };
36
- /**
37
- * Default `Logger` implementation. Holds a list of sinks and the
38
- * current scope; child loggers share the same sink list (so updating
39
- * the level / detaching at the root affects everything).
40
- */
41
36
  class CoreLogger {
42
37
  /**
43
38
  * Construct a root logger. Use `child(segment)` for sub-loggers.
@@ -47,7 +42,11 @@ class CoreLogger {
47
42
  this.scope = opts.scope ?? '';
48
43
  this.sinks = opts.sinks;
49
44
  this.level = opts.level ?? 'debug';
50
- this.sinksOwner = { sinks: this.sinks, level: this.level };
45
+ this.sinksOwner = {
46
+ sinks: this.sinks,
47
+ level: this.level,
48
+ counters: opts.sinks.map(() => ({ totalWrites: 0, failures: 0 })),
49
+ };
51
50
  }
52
51
  /** Internal — used by `child()` to share state with the root. */
53
52
  static childOf(parent, segment) {
@@ -73,6 +72,21 @@ class CoreLogger {
73
72
  }
74
73
  detachAll() {
75
74
  this.sinksOwner.sinks.length = 0;
75
+ this.sinksOwner.counters.length = 0;
76
+ }
77
+ getSinkHealth() {
78
+ const out = [];
79
+ for (let i = 0; i < this.sinksOwner.sinks.length; i += 1) {
80
+ const sink = this.sinksOwner.sinks[i];
81
+ const counter = this.sinksOwner.counters[i] ?? { totalWrites: 0, failures: 0 };
82
+ out.push({
83
+ name: sink.name ?? `sink:${i}`,
84
+ totalWrites: counter.totalWrites,
85
+ failures: counter.failures,
86
+ ...(counter.lastError ? { lastError: counter.lastError } : {}),
87
+ });
88
+ }
89
+ return out;
76
90
  }
77
91
  debug(msg, ctx) { this.write('debug', msg, ctx); }
78
92
  info(msg, ctx) { this.write('info', msg, ctx); }
@@ -89,12 +103,29 @@ class CoreLogger {
89
103
  ctx,
90
104
  };
91
105
  // Sinks must not throw — the helpers in ./sinks/* all wrap their
92
- // I/O in try/catch. Be defensive anyway.
93
- for (const s of this.sinksOwner.sinks) {
106
+ // I/O in try/catch. Be defensive anyway. Phase v4.1.2-slice3:
107
+ // bump the per-sink counter and capture the most recent failure
108
+ // message so `aiden doctor` can render it. The counter itself is
109
+ // never logged through this logger (would recurse).
110
+ for (let i = 0; i < this.sinksOwner.sinks.length; i += 1) {
111
+ const s = this.sinksOwner.sinks[i];
112
+ const c = this.sinksOwner.counters[i];
113
+ if (c)
114
+ c.totalWrites += 1;
94
115
  try {
95
116
  s.write(record);
96
117
  }
97
- catch { /* logging must not break callers */ }
118
+ catch (err) {
119
+ if (c) {
120
+ c.failures += 1;
121
+ const msg = err instanceof Error ? err.message : String(err);
122
+ c.lastError = {
123
+ message: msg.length > 200 ? msg.slice(0, 197) + '...' : msg,
124
+ at: new Date(),
125
+ };
126
+ }
127
+ /* logging must not break callers */
128
+ }
98
129
  }
99
130
  }
100
131
  }
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/promotionCandidates.ts — Phase v4.1.2-memory-D.
10
+ *
11
+ * Pure module that builds the list of "should we promote this to
12
+ * MEMORY.md `## Durable facts`?" candidates at session-end. The CLI
13
+ * surface (`cli/v4/promotionPrompt.ts`) reads the candidates, asks
14
+ * the user, and writes the approved subset.
15
+ *
16
+ * Sources combined (per Phase D's Q1 decision — A + B, defer C):
17
+ *
18
+ * A. Explicit user signals — regex over `history` user messages.
19
+ * "remember that X", "save this", "for next time", "don't forget"
20
+ * → the captured phrase becomes the candidate text. The
21
+ * surrounding user message is kept as `context` so the user can
22
+ * verify what they're promoting before approving.
23
+ *
24
+ * B. Distillation `decisions[]` + `open_items[]` — Phase A+B's
25
+ * structured output. Decisions are "X was chosen over Y"; open
26
+ * items are unfinished work / next-time prompts. Both are
27
+ * durable-worthy.
28
+ *
29
+ * C. Recurring facts across sessions — DEFERRED. Substring matching
30
+ * alone produces false positives ("any session mentioning Aiden"
31
+ * matches every other one). Semantic similarity belongs in Phase E
32
+ * alongside embeddings; lands when that slice ships.
33
+ *
34
+ * Priority ordering (drives the rendered list AND dedup-precedence):
35
+ * 1 — explicit (user EXPLICITLY asked to remember)
36
+ * 2 — decision (model identified as a settled decision)
37
+ * 3 — open_item (unfinished work — actionable next time)
38
+ *
39
+ * Dedup rules:
40
+ * - Within the candidate list: same-text (case-fold substring)
41
+ * candidates from multiple sources fold to highest priority.
42
+ * - Against existing durable body: substring-match every candidate
43
+ * against the caller's `existingDurableBody` (case-fold). Skipped
44
+ * candidates count toward `dedupedAgainstExisting` so the caller
45
+ * can surface the dim "N candidates already in durable facts"
46
+ * line per Phase D's Q5 first-run UX.
47
+ *
48
+ * Output cap: 10 candidates max (per Q3). Forces intentionality —
49
+ * sessions with 20+ durable-worthy items signal the user should
50
+ * reconsider what's actually durable.
51
+ */
52
+ Object.defineProperty(exports, "__esModule", { value: true });
53
+ exports.MAX_CANDIDATES = void 0;
54
+ exports.extractExplicitSignals = extractExplicitSignals;
55
+ exports.extractDistillationCandidates = extractDistillationCandidates;
56
+ exports.extractCandidates = extractCandidates;
57
+ exports.MAX_CANDIDATES = 10;
58
+ // ── Source A: explicit signals ────────────────────────────────────────────
59
+ /**
60
+ * Regex set for explicit promotion signals. Each pattern's capture
61
+ * group `[1]` is the phrase the user wants remembered. Anchored on
62
+ * word boundaries so partial-word matches don't fire ("remembering" ≠
63
+ * "remember").
64
+ *
65
+ * Capture semantics — Phase v4.1.2-bug-Z:
66
+ *
67
+ * Previous: capture terminated at any `[.!?\n]` (sentence boundary).
68
+ * That truncated common payloads with periods:
69
+ * - version strings: "gpt-5.5" → captured as "gpt-5"
70
+ * - URLs: "https://api.example.com/v1" → "https://api"
71
+ * - semver: "v1.2.3" → "v1"
72
+ * - filenames: "config.test.ts" → "config"
73
+ *
74
+ * Current: capture rest-of-payload until BLANK-LINE BOUNDARY
75
+ * (`\n\s*\n`) or end-of-string. Regex detects intent (the user said
76
+ * "remember"), not meaning (what counts as a sentence). The `s` flag
77
+ * makes `.` match newlines so multi-line payloads survive a single
78
+ * capture.
79
+ *
80
+ * Trade-off: when multiple markers fire within one sentence-run
81
+ * (no blank-line boundaries), the first-marker capture extends to
82
+ * end-of-payload and the second marker's narrower capture is
83
+ * dedup-folded into it. One candidate covers both facts. Cleanly
84
+ * paragraph-separated multi-markers still split because the
85
+ * blank-line boundary terminates the first capture before the
86
+ * second marker's position. The promotion prompt shows the full
87
+ * capture so the user approves knowingly.
88
+ */
89
+ // Separator tolerance: between the verb-phrase ("remember that",
90
+ // "save this", "don't forget to") and the fact, accept whitespace
91
+ // AND optional punctuation (`:`, `,`). Some users naturally write
92
+ // "remember that: the port is 4200" or "save this — we use X".
93
+ const SEP = '[\\s:,—-]+';
94
+ // Capture-end: blank line (`\n` + optional whitespace + `\n`) OR
95
+ // end-of-string. The `s` flag lets `.` match newlines so single-marker
96
+ // multi-line payloads stay in one capture.
97
+ const END = '(?:\\n\\s*\\n|$)';
98
+ const EXPLICIT_SIGNAL_PATTERNS = Object.freeze([
99
+ new RegExp(`\\bremember${SEP}(?:that|this)${SEP}(.+?)${END}`, 'gis'),
100
+ new RegExp(`\\bsave${SEP}(?:this|that)${SEP}(?:to memory${SEP})?(.+?)${END}`, 'gis'),
101
+ new RegExp(`\\bfor next time${SEP}(.+?)${END}`, 'gis'),
102
+ new RegExp(`\\bdon'?t forget${SEP}(?:that|to)${SEP}(.+?)${END}`, 'gis'),
103
+ ]);
104
+ /**
105
+ * Strip leading filler that often slips past the regex's "that|this"
106
+ * anchor ("that the", "this — "), and trim. Empty / too-short results
107
+ * are signalled by returning the empty string; caller drops them.
108
+ */
109
+ function cleanCandidateText(raw) {
110
+ let s = raw.trim();
111
+ // Drop leading "that ", "this ", "to " (the regex caught them
112
+ // sometimes when they sat between the verb and the fact).
113
+ s = s.replace(/^(?:that|this|to)\s+/i, '').trim();
114
+ // Drop trailing punctuation noise.
115
+ s = s.replace(/[\s,;:]+$/, '');
116
+ return s;
117
+ }
118
+ function extractExplicitSignals(history) {
119
+ const out = [];
120
+ for (const msg of history) {
121
+ if (msg.role !== 'user')
122
+ continue;
123
+ const text = typeof msg.content === 'string' ? msg.content : '';
124
+ if (!text)
125
+ continue;
126
+ for (const pat of EXPLICIT_SIGNAL_PATTERNS) {
127
+ // Recreate per-message so the global flag resets.
128
+ const re = new RegExp(pat.source, pat.flags);
129
+ let m;
130
+ while ((m = re.exec(text)) !== null) {
131
+ const cleaned = cleanCandidateText(m[1] ?? '');
132
+ if (cleaned.length < 4)
133
+ continue; // 1-3 char hits are noise
134
+ out.push({
135
+ text: cleaned,
136
+ source: 'explicit',
137
+ context: text.trim(),
138
+ priority: 1,
139
+ });
140
+ }
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+ // ── Source B: distillation decisions + open_items ─────────────────────────
146
+ function extractDistillationCandidates(dist) {
147
+ const out = [];
148
+ for (const d of dist.decisions) {
149
+ const t = d.trim();
150
+ if (t.length >= 4) {
151
+ out.push({ text: t, source: 'decision', priority: 2 });
152
+ }
153
+ }
154
+ for (const o of dist.open_items) {
155
+ const t = o.trim();
156
+ if (t.length >= 4) {
157
+ out.push({ text: t, source: 'open_item', priority: 3 });
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+ // ── Dedup + ranking ───────────────────────────────────────────────────────
163
+ function normalize(s) {
164
+ return s.toLowerCase().replace(/\s+/g, ' ').trim();
165
+ }
166
+ /**
167
+ * Within-session dedup: when the same fact surfaces from multiple
168
+ * sources, keep the highest-priority one. Substring-match in both
169
+ * directions so "Aiden runs on port 4200" and "Port 4200 for Aiden"
170
+ * collide on the longer-containing case.
171
+ */
172
+ function dedupWithinSession(input) {
173
+ const sorted = [...input].sort((a, b) => a.priority - b.priority);
174
+ const kept = [];
175
+ let dropped = 0;
176
+ for (const c of sorted) {
177
+ const normC = normalize(c.text);
178
+ const collision = kept.some((k) => {
179
+ const normK = normalize(k.text);
180
+ return normK.includes(normC) || normC.includes(normK);
181
+ });
182
+ if (collision) {
183
+ dropped += 1;
184
+ continue;
185
+ }
186
+ kept.push(c);
187
+ }
188
+ return { kept, dropped };
189
+ }
190
+ /**
191
+ * Dedup against existing `## Durable facts` body. Substring-match
192
+ * each candidate against the body (case-fold). Skipped candidates
193
+ * count toward the returned `dropped` so the caller can render the
194
+ * "N already in durable facts" dim line.
195
+ */
196
+ function dedupAgainstExisting(input, existing) {
197
+ if (!existing.trim())
198
+ return { kept: [...input], dropped: 0 };
199
+ const normExisting = normalize(existing);
200
+ const kept = [];
201
+ let dropped = 0;
202
+ for (const c of input) {
203
+ if (normExisting.includes(normalize(c.text))) {
204
+ dropped += 1;
205
+ continue;
206
+ }
207
+ kept.push(c);
208
+ }
209
+ return { kept, dropped };
210
+ }
211
+ // ── Public entry point ────────────────────────────────────────────────────
212
+ /**
213
+ * Build the full candidate list. Combines source A + source B,
214
+ * dedups within session, dedups against existing durable body, sorts
215
+ * by priority (stable within priority by source order — explicit
216
+ * signals before decisions before open items), and caps at 10.
217
+ */
218
+ function extractCandidates(history, distillation, existingDurableBody) {
219
+ const rawA = extractExplicitSignals(history);
220
+ const rawB = extractDistillationCandidates(distillation);
221
+ const totalBeforeDedup = rawA.length + rawB.length;
222
+ const within = dedupWithinSession([...rawA, ...rawB]);
223
+ const against = dedupAgainstExisting(within.kept, existingDurableBody);
224
+ // Stable sort by priority — within a priority tier, preserve insertion
225
+ // order so explicit signals land in chronological message order and
226
+ // decisions land in distillation order.
227
+ const sorted = [...against.kept].sort((a, b) => a.priority - b.priority);
228
+ return {
229
+ candidates: sorted.slice(0, exports.MAX_CANDIDATES),
230
+ dedupedAgainstExisting: against.dropped,
231
+ dedupedWithinSession: within.dropped,
232
+ totalBeforeDedup,
233
+ };
234
+ }