@cstart/coldstart 2.2.7 → 2.2.9
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/LICENSE +21 -0
- package/README.md +26 -1
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +20 -6
- package/dist/init.js.map +1 -1
- package/dist/kb/cli.d.ts.map +1 -1
- package/dist/kb/cli.js +38 -1
- package/dist/kb/cli.js.map +1 -1
- package/dist/kb/search.d.ts +15 -0
- package/dist/kb/search.d.ts.map +1 -1
- package/dist/kb/search.js +195 -18
- package/dist/kb/search.js.map +1 -1
- package/dist/kb/view.d.ts +9 -1
- package/dist/kb/view.d.ts.map +1 -1
- package/dist/kb/view.js +16 -2
- package/dist/kb/view.js.map +1 -1
- package/dist/server/mcp.d.ts.map +1 -1
- package/dist/server/mcp.js +33 -3
- package/dist/server/mcp.js.map +1 -1
- package/hooks/capture-payload.mjs +29 -14
- package/hooks/codex-kb-recall.mjs +19 -3
- package/hooks/cursor-kb-recall.mjs +15 -2
- package/hooks/elicit-core.mjs +4 -1
- package/hooks/kb-elicit.mjs +77 -9
- package/hooks/kb-recall.mjs +18 -2
- package/hooks/recall-query.mjs +83 -0
- package/hooks/recall-seen.mjs +76 -0
- package/hooks/trigger.mjs +47 -11
- package/package.json +4 -3
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recall-query.mjs — turn a raw UserPromptSubmit payload into a recall QUERY.
|
|
3
|
+
*
|
|
4
|
+
* The hosts do not hand us the user's words. They hand us the user's words
|
|
5
|
+
* WRAPPED IN HARNESS TELEMETRY: the file that happens to be open in the editor,
|
|
6
|
+
* the current selection, a finished subagent's summary, the expansion of a
|
|
7
|
+
* slash command. That text is file- and symbol-rich, so `kb search --hook`
|
|
8
|
+
* treats it as if the user had named those files — most damagingly through the
|
|
9
|
+
* path-name override in src/kb/search.ts, which boosts a note past every
|
|
10
|
+
* eligibility gate when the query string contains its anchor path. A stale-open
|
|
11
|
+
* editor tab then hijacks recall for turns at a time.
|
|
12
|
+
*
|
|
13
|
+
* So: strip the wrappers (tag AND content) before the query is built, and
|
|
14
|
+
* before truncation — a 1.5KB <ide_selection> ahead of a short question would
|
|
15
|
+
* otherwise eat the whole MAX_QUERY_CHARS budget and leave the real ask cut off.
|
|
16
|
+
*
|
|
17
|
+
* Only a NAMED set of tags is stripped. Anything else — including XML or HTML
|
|
18
|
+
* the user pasted deliberately — is left alone; over-stripping would silently
|
|
19
|
+
* drop real questions, which is the failure this is meant to prevent.
|
|
20
|
+
*
|
|
21
|
+
* Fail-open: on any error the raw text is returned unchanged.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Harness-emitted wrappers, union across Claude Code / Cursor / Codex. A tag
|
|
25
|
+
* a given host never emits simply never matches. */
|
|
26
|
+
export const HARNESS_TAGS = [
|
|
27
|
+
// Claude Code
|
|
28
|
+
"system-reminder",
|
|
29
|
+
"ide_opened_file",
|
|
30
|
+
"ide_selection",
|
|
31
|
+
"ide_diagnostics",
|
|
32
|
+
"task-notification",
|
|
33
|
+
"local-command-caveat",
|
|
34
|
+
"local-command-stdout",
|
|
35
|
+
"local-command-stderr",
|
|
36
|
+
"command-name",
|
|
37
|
+
"command-message",
|
|
38
|
+
"command-args",
|
|
39
|
+
"user-prompt-submit-hook",
|
|
40
|
+
// Cursor
|
|
41
|
+
"browser_instruction",
|
|
42
|
+
// Codex
|
|
43
|
+
"environment_context",
|
|
44
|
+
"user_instructions",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const NAMES = HARNESS_TAGS.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
48
|
+
// Well-formed pair, content included. Non-greedy, /s so it spans newlines.
|
|
49
|
+
const PAIRED = new RegExp(`<(${NAMES})(?:\\s[^>]*)?>[\\s\\S]*?<\\/\\1\\s*>`, "gi");
|
|
50
|
+
// Leftovers: self-closing, or a lone open/close tag from a truncated payload.
|
|
51
|
+
const LONE = new RegExp(`<\\/?(?:${NAMES})(?:\\s[^>]*)?\\/?>`, "gi");
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Strip harness wrappers from a raw prompt.
|
|
55
|
+
* @param {string} raw
|
|
56
|
+
* @returns {string} the user's own text; "" when nothing but telemetry remains.
|
|
57
|
+
*/
|
|
58
|
+
export function stripHarnessWrappers(raw) {
|
|
59
|
+
try {
|
|
60
|
+
let s = String(raw ?? "");
|
|
61
|
+
if (!s) return "";
|
|
62
|
+
// Nested wrappers (a system-reminder inside a task-notification) need more
|
|
63
|
+
// than one pass; bounded so a pathological payload cannot spin.
|
|
64
|
+
for (let i = 0; i < 4; i++) {
|
|
65
|
+
const next = s.replace(PAIRED, " ");
|
|
66
|
+
if (next === s) break;
|
|
67
|
+
s = next;
|
|
68
|
+
}
|
|
69
|
+
return s.replace(LONE, " ").replace(/[ \t]+/g, " ").trim();
|
|
70
|
+
} catch {
|
|
71
|
+
return String(raw ?? "");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The full prompt → query pipeline: strip, THEN truncate.
|
|
77
|
+
* @param {string} raw raw `input.prompt`
|
|
78
|
+
* @param {number} maxChars
|
|
79
|
+
* @returns {string} "" when there is nothing left to search for.
|
|
80
|
+
*/
|
|
81
|
+
export function recallQuery(raw, maxChars) {
|
|
82
|
+
return stripHarnessWrappers(raw).slice(0, maxChars).trim();
|
|
83
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* recall-seen.mjs — per-session dedup state for the recall hooks.
|
|
4
|
+
*
|
|
5
|
+
* 61% of notebook injections re-showed a note the SAME session had already
|
|
6
|
+
* been given (worst case 12x). That is pure tax: the title and gist are
|
|
7
|
+
* already in the agent's context, so the repeat buys nothing and pushes the
|
|
8
|
+
* page toward the host's size cap. Each recall hook remembers the ids it has
|
|
9
|
+
* shown and passes them to `kb search --seen`, which drops them from the page
|
|
10
|
+
* it would otherwise have rendered (no backfill; all-seen => inject nothing).
|
|
11
|
+
*
|
|
12
|
+
* Reset on COMPACTION, not on a clock. After a compact the session id is
|
|
13
|
+
* unchanged but the agent's context is gone, so a previously-shown note is
|
|
14
|
+
* genuinely absent again and SHOULD be re-injectable. A shrinking transcript
|
|
15
|
+
* file is the observable signal — the same one kb-elicit uses for its own
|
|
16
|
+
* resume handling. Hosts that expose no transcript path (ephemeral Codex runs)
|
|
17
|
+
* simply never reset: dedup still works, it just holds for the whole session.
|
|
18
|
+
*
|
|
19
|
+
* Shared by kb-recall.mjs (Claude), cursor-kb-recall.mjs and
|
|
20
|
+
* codex-kb-recall.mjs so the three cannot drift. Every function is fail-open:
|
|
21
|
+
* any error leaves dedup OFF rather than blocking recall.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync, writeFileSync, statSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
|
|
28
|
+
const SEEN_TTL_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
/** os.tmpdir(), NOT literal /tmp — that path is reserved for the find_nudge
|
|
30
|
+
* state file, which the nudge handler reads back by an agreed literal path. */
|
|
31
|
+
const seenPath = (sid) => join(tmpdir(), `coldstart-recall-seen-${sid}.json`);
|
|
32
|
+
|
|
33
|
+
function transcriptSize(p) {
|
|
34
|
+
try { return p ? statSync(p).size : 0; } catch { return 0; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ids already shown this session. `log` is optional (each host has its own).
|
|
39
|
+
* Returns { ids, size } — pass both back to writeSeen.
|
|
40
|
+
*/
|
|
41
|
+
export function readSeen(sid, transcriptPath, log) {
|
|
42
|
+
if (!sid) return { ids: [], size: 0 };
|
|
43
|
+
try {
|
|
44
|
+
const st = JSON.parse(readFileSync(seenPath(sid), "utf8"));
|
|
45
|
+
if (!st || !Array.isArray(st.ids)) return { ids: [], size: 0 };
|
|
46
|
+
if (Date.now() - (st.ts || 0) > SEEN_TTL_MS) return { ids: [], size: 0 };
|
|
47
|
+
const now = transcriptSize(transcriptPath);
|
|
48
|
+
if (now && st.size && now < st.size) {
|
|
49
|
+
log?.(`compaction detected (${st.size} -> ${now} bytes): clearing ${st.ids.length} seen ids`);
|
|
50
|
+
return { ids: [], size: now };
|
|
51
|
+
}
|
|
52
|
+
return { ids: st.ids, size: st.size || 0 };
|
|
53
|
+
} catch { return { ids: [], size: 0 }; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function writeSeen(sid, ids, transcriptPath) {
|
|
57
|
+
if (!sid) return;
|
|
58
|
+
try {
|
|
59
|
+
writeFileSync(seenPath(sid), JSON.stringify({
|
|
60
|
+
ids: [...new Set(ids)].slice(-200), // bounded; a session never shows this many
|
|
61
|
+
size: transcriptSize(transcriptPath),
|
|
62
|
+
ts: Date.now(),
|
|
63
|
+
}));
|
|
64
|
+
} catch { /* fail-open: dedup is an optimisation, never a gate */ }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Note ids on a rendered pointer page — each hit carries `→ open: …/<id>.md`.
|
|
68
|
+
* Read back off the rendered text so the hooks need no structured output. */
|
|
69
|
+
export function idsInPage(page) {
|
|
70
|
+
return [...page.matchAll(/→ open:\s*\S*?notes\/([\w.-]+)\.md/g)].map((m) => m[1]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The `--seen a,b,c` argv fragment, or [] when there is nothing to exclude. */
|
|
74
|
+
export function seenArgs(ids) {
|
|
75
|
+
return ids.length ? ["--seen", ids.join(",")] : [];
|
|
76
|
+
}
|
package/hooks/trigger.mjs
CHANGED
|
@@ -3,18 +3,34 @@
|
|
|
3
3
|
* clock, no client specifics. The hook feeds it one stop's observations and
|
|
4
4
|
* it returns the updated state + a fire decision.
|
|
5
5
|
*
|
|
6
|
-
* FROZEN SPEC (2026-07-15, replay + wave-lab validated; 2026-07-21 descent/synthesis update
|
|
6
|
+
* FROZEN SPEC (2026-07-15, replay + wave-lab validated; 2026-07-21 descent/synthesis update;
|
|
7
|
+
* 2026-07-24 "option C" — see the REGRESSION note below):
|
|
7
8
|
* score = uncaptured contentRead files ×1
|
|
8
9
|
* + settled edited files ×2 (settled = 3 active stops w/o re-edit)
|
|
9
|
-
* +
|
|
10
|
+
* + ALL stops since last fire (every stop is engagement; see below)
|
|
10
11
|
* fresh-noted files contribute NOTHING — genuinely new knowledge drives firing.
|
|
11
|
-
* arm at score ≥ T(10), requiring ≥
|
|
12
|
+
* arm at score ≥ T(10), requiring ≥1 active stop AND ≥1 uncaptured file.
|
|
12
13
|
* fire armed + descent (1 quiet stop) → non-blocking (inject)
|
|
13
|
-
* score ≥ CAP(20)
|
|
14
|
+
* score ≥ CAP(20), ≥2 uncaptured files → non-blocking (backlog
|
|
14
15
|
* rescue — replay showed dense sessions starve descent and hit cap
|
|
15
16
|
* repeatedly; blocking each cap re-created the v4 agitation)
|
|
16
17
|
* .git/HEAD drift, ≥2 uncaptured files → BLOCKING (instant —
|
|
17
18
|
* the one boundary where waiting for a next prompt loses the moment)
|
|
19
|
+
*
|
|
20
|
+
* REGRESSION FIXED 2026-07-24 (why the stop term is ALL stops, not active ones):
|
|
21
|
+
* The 2026-07-21 change made synthesis turns count as QUIET so they could feed
|
|
22
|
+
* descent. Correct for descent — but the score's stop term read `activeStops`,
|
|
23
|
+
* so the same edit silently DELETED the only score-growth term a discussion
|
|
24
|
+
* session has. Measured consequence: descent fired ZERO times in this repo's
|
|
25
|
+
* entire history (104 stops / 24 fires — all head-drift or cap). A session that
|
|
26
|
+
* reads 3 files then discusses them for 8 turns sat at score 4 forever.
|
|
27
|
+
* The two questions are now asked of different counters, which is the point:
|
|
28
|
+
* "has enough happened?" → score, counting EVERY stop (engagement)
|
|
29
|
+
* "has it wound down?" → quietRun, counting only non-active stops
|
|
30
|
+
* The ≥2-active-stops arming gate went with it: a discussion session bursts its
|
|
31
|
+
* file reads in ONE stop, so that gate alone would have kept the fix inert.
|
|
32
|
+
* Blocking paths (head-drift) keep MIN_FILES=2 — relaxing those to 1 re-creates
|
|
33
|
+
* the v4 single-file agitation this design exists to kill.
|
|
18
34
|
* NEVER: first-stop fire, wall-clock, gap/resume, conversation classification.
|
|
19
35
|
* (surge removed 2026-07-21: with descent=1 a quiet stop fires descent before
|
|
20
36
|
* surge could ever apply — the two were redundant. cap + head-drift remain the
|
|
@@ -27,15 +43,27 @@
|
|
|
27
43
|
|
|
28
44
|
export const T_ARM = 10;
|
|
29
45
|
export const T_CAP = 20;
|
|
46
|
+
/** Files a single fire CLAIMS (marks captured) and lists — the same cap the
|
|
47
|
+
* worklist prompt renders (elicit-core slices its display to it). Files beyond
|
|
48
|
+
* it stay UNCAPTURED so they roll into the NEXT fire's worklist, rather than
|
|
49
|
+
* being marked done but never shown — which silently dropped notes on sessions
|
|
50
|
+
* that touched more than this many files (a long autonomous grind). */
|
|
51
|
+
export const MAX_CAPTURE_FILES = 30;
|
|
30
52
|
export const SETTLE_ACTIVE_STOPS = 3;
|
|
31
53
|
export const DESCENT_QUIET = 1;
|
|
54
|
+
/** Uncaptured-file floor for the BLOCKING/backlog paths (head-drift, cap).
|
|
55
|
+
* Stays at 2 deliberately: a one-file blocking prompt is the v4 agitation. */
|
|
32
56
|
export const MIN_FILES = 2;
|
|
57
|
+
/** Floor for the non-blocking paths (arm → descent). One real file is enough to
|
|
58
|
+
* be worth a note, and descent only ever injects. */
|
|
59
|
+
export const MIN_FILES_ARM = 1;
|
|
33
60
|
|
|
34
61
|
export function initialState() {
|
|
35
62
|
return {
|
|
36
63
|
v: 2,
|
|
37
64
|
stop: 0, // stops processed
|
|
38
|
-
activeStops: 0, // ACTIVE stops since last fire
|
|
65
|
+
activeStops: 0, // ACTIVE stops since last fire (settle clock + arming floor)
|
|
66
|
+
stopsSinceFire: 0, // ALL stops since last fire (score's engagement term)
|
|
39
67
|
quietRun: 0, // consecutive quiet stops
|
|
40
68
|
armed: false,
|
|
41
69
|
fires: 0,
|
|
@@ -99,6 +127,9 @@ export function step(state, obs) {
|
|
|
99
127
|
// so it counts as quiet, not active.
|
|
100
128
|
const active = newFiles > 0 || editsThisStop > 0;
|
|
101
129
|
if (active) { s.activeStops++; s.quietRun = 0; } else { s.quietRun++; }
|
|
130
|
+
// Engagement, counted separately from "work happened". `|| 0` tolerates
|
|
131
|
+
// markers written before this field existed (a live session mid-upgrade).
|
|
132
|
+
s.stopsSinceFire = (s.stopsSinceFire || 0) + 1;
|
|
102
133
|
for (const f of Object.values(s.files)) {
|
|
103
134
|
if (f.lastEditActive === -2) f.lastEditActive = s.activeStops; // edit stamped at current active count
|
|
104
135
|
}
|
|
@@ -109,10 +140,10 @@ export function step(state, obs) {
|
|
|
109
140
|
const settledEdits = uncaptured.filter(([, f]) =>
|
|
110
141
|
f.edits > 0 && f.lastEditActive >= 0 && s.activeStops - f.lastEditActive >= SETTLE_ACTIVE_STOPS,
|
|
111
142
|
).length;
|
|
112
|
-
const score = readPts + settledEdits * 2 + s.
|
|
143
|
+
const score = readPts + settledEdits * 2 + s.stopsSinceFire;
|
|
113
144
|
|
|
114
145
|
// ---- arm / fire ----------------------------------------------------------------
|
|
115
|
-
if (!s.armed && score >= T_ARM && s.activeStops >=
|
|
146
|
+
if (!s.armed && score >= T_ARM && s.activeStops >= 1 && uncaptured.length >= MIN_FILES_ARM) {
|
|
116
147
|
s.armed = true;
|
|
117
148
|
}
|
|
118
149
|
|
|
@@ -127,13 +158,18 @@ export function step(state, obs) {
|
|
|
127
158
|
|
|
128
159
|
let decision = null;
|
|
129
160
|
if (fire) {
|
|
130
|
-
// worklist = the uncaptured set, most-worked first (edits, then retouches)
|
|
131
|
-
|
|
161
|
+
// worklist = the uncaptured set, most-worked first (edits, then retouches),
|
|
162
|
+
// capped to what one fire claims. Mark ONLY the listed files captured; the
|
|
163
|
+
// overflow stays uncaptured and rolls into the next fire (which re-fires
|
|
164
|
+
// soon, since the backlog keeps the score high).
|
|
165
|
+
const ranked = uncaptured
|
|
132
166
|
.sort((a, b) => (b[1].edits - a[1].edits) || (b[1].retouches - a[1].retouches) || (b[1].reads - a[1].reads))
|
|
133
|
-
.
|
|
134
|
-
|
|
167
|
+
.slice(0, MAX_CAPTURE_FILES);
|
|
168
|
+
const files = ranked.map(([rel]) => rel);
|
|
169
|
+
for (const [, f] of ranked) f.captured = true;
|
|
135
170
|
s.armed = false;
|
|
136
171
|
s.activeStops = 0;
|
|
172
|
+
s.stopsSinceFire = 0;
|
|
137
173
|
s.quietRun = 0;
|
|
138
174
|
s.fires++;
|
|
139
175
|
decision = { ...fire, files, score };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cstart/coldstart",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.9",
|
|
4
4
|
"mcpName": "io.github.AkashGoenka/coldstart",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -39,10 +39,11 @@
|
|
|
39
39
|
"tree-sitter"
|
|
40
40
|
],
|
|
41
41
|
"license": "MIT",
|
|
42
|
+
"author": "Akash Goenka (https://github.com/AkashGoenka)",
|
|
42
43
|
"homepage": "https://akashgoenka.github.io/coldstart/",
|
|
43
44
|
"repository": {
|
|
44
45
|
"type": "git",
|
|
45
|
-
"url": "https://github.com/AkashGoenka/coldstart"
|
|
46
|
+
"url": "git+https://github.com/AkashGoenka/coldstart.git"
|
|
46
47
|
},
|
|
47
48
|
"bugs": {
|
|
48
49
|
"url": "https://github.com/AkashGoenka/coldstart/issues"
|
|
@@ -54,7 +55,7 @@
|
|
|
54
55
|
"test:watch": "vitest"
|
|
55
56
|
},
|
|
56
57
|
"dependencies": {
|
|
57
|
-
"@modelcontextprotocol/sdk": "
|
|
58
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
58
59
|
"@vscode/ripgrep": "^1.18.0",
|
|
59
60
|
"web-tree-sitter": "^0.26.10"
|
|
60
61
|
},
|