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.
- package/README.md +89 -33
- package/dist/cli/v4/aidenCLI.js +162 -11
- package/dist/cli/v4/callbacks.js +5 -2
- package/dist/cli/v4/chatSession.js +525 -15
- package/dist/cli/v4/commands/auth.js +6 -3
- package/dist/cli/v4/commands/help.js +4 -0
- package/dist/cli/v4/commands/index.js +10 -1
- package/dist/cli/v4/commands/reloadSoul.js +37 -0
- package/dist/cli/v4/commands/update.js +102 -0
- package/dist/cli/v4/defaultSoul.js +68 -2
- package/dist/cli/v4/display.js +28 -10
- package/dist/cli/v4/doctor.js +173 -1
- package/dist/cli/v4/doctorLiveness.js +384 -0
- package/dist/cli/v4/promotionPrompt.js +202 -0
- package/dist/cli/v4/providerBootSelector.js +144 -0
- package/dist/cli/v4/sessionSummaryGate.js +66 -0
- package/dist/cli/v4/toolPreview.js +139 -0
- package/dist/core/v4/aidenAgent.js +91 -29
- package/dist/core/v4/capabilities.js +89 -0
- package/dist/core/v4/contextCompressor.js +25 -8
- package/dist/core/v4/distillationIndex.js +167 -0
- package/dist/core/v4/distillationStore.js +98 -0
- package/dist/core/v4/logger/logger.js +40 -9
- package/dist/core/v4/promotionCandidates.js +234 -0
- package/dist/core/v4/promptBuilder.js +145 -1
- package/dist/core/v4/sessionDistiller.js +405 -0
- package/dist/core/v4/skillMining/extractorPrompt.js +28 -21
- package/dist/core/v4/skillMining/proposalBuilder.js +3 -2
- package/dist/core/v4/skillMining/skillMiner.js +43 -6
- package/dist/core/v4/skillOutcomeTracker.js +323 -0
- package/dist/core/v4/subsystemHealth.js +143 -0
- package/dist/core/v4/update/executeInstall.js +233 -0
- package/dist/core/version.js +1 -1
- package/dist/moat/dangerousPatterns.js +1 -1
- package/dist/moat/memoryGuard.js +111 -0
- package/dist/moat/skillTeacher.js +14 -5
- package/dist/providers/v4/chatCompletionsAdapter.js +9 -0
- package/dist/providers/v4/codexResponsesAdapter.js +7 -2
- package/dist/providers/v4/errors.js +67 -1
- package/dist/providers/v4/modelDefaults.js +65 -0
- package/dist/providers/v4/ollamaPromptToolsAdapter.js +9 -2
- package/dist/providers/v4/registry.js +9 -2
- package/dist/providers/v4/runtimeResolver.js +6 -0
- package/dist/tools/v4/index.js +57 -1
- package/dist/tools/v4/memory/memoryRemove.js +57 -2
- package/dist/tools/v4/memory/sessionSummary.js +151 -0
- package/dist/tools/v4/sessions/recallSession.js +163 -0
- package/dist/tools/v4/sessions/sessionSearch.js +5 -1
- package/dist/tools/v4/subagent/subagentFanout.js +24 -0
- package/dist/tools/v4/system/_psHelpers.js +55 -0
- package/dist/tools/v4/system/aidenSelfUpdate.js +162 -0
- package/dist/tools/v4/system/appClose.js +79 -0
- package/dist/tools/v4/system/appLaunch.js +92 -0
- package/dist/tools/v4/system/clipboardRead.js +54 -0
- package/dist/tools/v4/system/clipboardWrite.js +84 -0
- package/dist/tools/v4/system/mediaKey.js +78 -0
- package/dist/tools/v4/system/osProcessList.js +99 -0
- package/dist/tools/v4/system/screenshot.js +106 -0
- package/dist/tools/v4/system/volumeSet.js +157 -0
- package/package.json +4 -1
- 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 = {
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|