@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.
@@ -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
- * + active stops since last fire (new files or edits only; synthesis no longer counts as active)
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 ≥2 active stops AND ≥2 uncaptured files.
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) → non-blocking (backlog
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.activeStops;
143
+ const score = readPts + settledEdits * 2 + s.stopsSinceFire;
113
144
 
114
145
  // ---- arm / fire ----------------------------------------------------------------
115
- if (!s.armed && score >= T_ARM && s.activeStops >= 2 && uncaptured.length >= MIN_FILES) {
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
- const files = uncaptured
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
- .map(([rel]) => rel);
134
- for (const [, f] of uncaptured) f.captured = true;
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.7",
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": "latest",
58
+ "@modelcontextprotocol/sdk": "^1.29.0",
58
59
  "@vscode/ripgrep": "^1.18.0",
59
60
  "web-tree-sitter": "^0.26.10"
60
61
  },