@lh8ppl/claude-memory-kit 0.3.4 → 0.4.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.
Files changed (47) hide show
  1. package/README.md +6 -0
  2. package/bin/cmk-guard-memory.mjs +57 -0
  3. package/package.json +3 -2
  4. package/src/agent-profile.mjs +115 -0
  5. package/src/agent-profiles.mjs +118 -0
  6. package/src/auto-persona.mjs +4 -1
  7. package/src/compress-retry.mjs +25 -0
  8. package/src/compress-session.mjs +27 -3
  9. package/src/config-core.mjs +7 -9
  10. package/src/daily-distill.mjs +7 -3
  11. package/src/decisions-journal.mjs +71 -3
  12. package/src/doctor.mjs +86 -4
  13. package/src/guard-memory.mjs +151 -0
  14. package/src/import-anthropic-memory.mjs +15 -1
  15. package/src/inject-context.mjs +34 -3
  16. package/src/install-agent.mjs +220 -0
  17. package/src/install-kiro.mjs +287 -0
  18. package/src/install.mjs +16 -3
  19. package/src/kiro-cli-agent.mjs +270 -0
  20. package/src/kiro-constants.mjs +19 -0
  21. package/src/kiro-hook-bin.mjs +105 -0
  22. package/src/kiro-hook-command.mjs +67 -0
  23. package/src/kiro-hook-dispatch.mjs +115 -0
  24. package/src/kiro-ide-hooks.mjs +219 -0
  25. package/src/kiro-permissions.mjs +175 -0
  26. package/src/kiro-skills.mjs +96 -0
  27. package/src/kiro-transcript.mjs +366 -0
  28. package/src/kiro-trusted-commands.mjs +130 -0
  29. package/src/lazy-compress.mjs +6 -0
  30. package/src/managed-block.mjs +138 -0
  31. package/src/memory-write.mjs +23 -8
  32. package/src/mutate-agent-config.mjs +243 -0
  33. package/src/read-json.mjs +43 -0
  34. package/src/reindex.mjs +15 -2
  35. package/src/repair.mjs +39 -3
  36. package/src/result-shapes.mjs +8 -0
  37. package/src/review-queue.mjs +3 -0
  38. package/src/scratchpad.mjs +12 -2
  39. package/src/search.mjs +12 -5
  40. package/src/semantic-backend.mjs +7 -9
  41. package/src/settings-hooks.mjs +12 -2
  42. package/src/subcommands.mjs +360 -27
  43. package/src/tier-paths.mjs +48 -1
  44. package/src/weekly-curate.mjs +13 -6
  45. package/template/.claude/skills/memory-search/SKILL.md +14 -1
  46. package/template/.claude/skills/memory-write/SKILL.md +37 -1
  47. package/template/project/memory/INDEX.md.template +1 -1
@@ -0,0 +1,175 @@
1
+ // kiro-permissions.mjs — write Kiro IDE 1.0's authoritative trust store so the
2
+ // kit's surfaces run prompt-free (Task 50.N.5 / D-203h/D-203i).
3
+ //
4
+ // On Kiro IDE 1.0, the LIVE trust is `~/.kiro/workspace-roots/<hash>/
5
+ // permissions.yaml` (capability/match/effect), NOT `.vscode/settings.json` — Kiro
6
+ // auto-MIGRATES the latter into the former at first open (D-203i: proven by the
7
+ // `.trust-migration.json` sibling + MCP running prompt-free with no `.vscode`
8
+ // trustedMcpTools). There is NO `.vscode` setting for SKILL trust, so the only
9
+ // reliable pre-trust for the memory-write skill-load prompt is to write
10
+ // permissions.yaml directly.
11
+ //
12
+ // <hash> = sha256( projectRoot, normalized: forward-slash + no-trailing-slash +
13
+ // lowercase ).hexdigest().slice(0,16) — VERIFIED on a real install (D-203h:
14
+ // c:/temp/kiro-ide-gate → a7ffdb64ec4c31c8).
15
+ //
16
+ // Format (ground-truth, read from a real grant — D-203h):
17
+ // rules:
18
+ // - { capability: shell, match: [cmd.exe /c cmk hook *, ...], effect: allow }
19
+ // - { capability: mcp, match: [claude-memory-kit/mk_remember, ...], effect: allow }
20
+ // - { capability: skill, match: [memory-write, memory-search], effect: allow }
21
+ //
22
+ // Public surface:
23
+ // kiroWorkspaceHash(projectRoot) → string (the 16-hex workspace key)
24
+ // installKiroPermissions({ projectRoot, env? }) → { action, changed, path }
25
+ // uninstallKiroPermissions({ projectRoot, env? }) → { action, changed }
26
+ //
27
+ // Managed-merge: we own ONLY the rules whose capability+match are ours (matched
28
+ // by the kit's known tokens); a user's own rules are byte-preserved on install +
29
+ // uninstall (the over-mutation discipline).
30
+
31
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
32
+ import { homedir } from 'node:os';
33
+ import { join, dirname, resolve } from 'node:path';
34
+ import { createHash } from 'node:crypto';
35
+ import yaml from 'js-yaml';
36
+
37
+ import { MCP_AUTO_APPROVE } from './kiro-constants.mjs';
38
+
39
+ // The kit's MCP tools, namespaced as Kiro's permissions.yaml lists them
40
+ // (server/tool). MCP_AUTO_APPROVE is the shared source of the 11 tool names
41
+ // (also used for the IDE's mcp.json autoApprove), so the two never drift.
42
+ const MCP_MATCH = MCP_AUTO_APPROVE.map((t) => `claude-memory-kit/${t}`);
43
+ const SHELL_MATCH = Object.freeze(['cmd.exe /c cmk hook *', 'cmd.exe /c cmk-guard-memory*']);
44
+ const SKILL_MATCH = Object.freeze(['memory-write', 'memory-search']);
45
+
46
+ // The kit's owned MATCH ENTRIES, per capability+effect. Ownership is PER-ENTRY
47
+ // (not per-rule): Kiro stores ONE rule per (capability, effect) with a combined
48
+ // `match` array, so a user can co-locate their own match in the SAME rule as ours.
49
+ // We add/remove only OUR entries and never drop a co-located user entry (the
50
+ // over-mutation discipline — mirrors kiro-trusted-commands.mjs's per-entry filter,
51
+ // skill-review B1).
52
+ const OUR_MATCHES = Object.freeze({
53
+ shell: SHELL_MATCH,
54
+ mcp: MCP_MATCH,
55
+ skill: SKILL_MATCH,
56
+ });
57
+ function isOurMatch(capability, entry) {
58
+ const owned = OUR_MATCHES[capability];
59
+ return Array.isArray(owned) && owned.includes(entry);
60
+ }
61
+
62
+ // Merge our match entries into an existing rules array, PER-ENTRY + PER-(capability,
63
+ // effect:allow) rule. Preserves the user's rules + their co-located match entries;
64
+ // adds only the missing OUR entries; keeps rule order stable (in-place, no float).
65
+ function withOurRules(existing) {
66
+ // clone so we never mutate the parsed input
67
+ const rules = existing.map((r) => ({ ...r, match: Array.isArray(r.match) ? [...r.match] : r.match }));
68
+ for (const [capability, owned] of Object.entries(OUR_MATCHES)) {
69
+ // find the existing allow-rule for this capability (Kiro's convention: one per cap)
70
+ let rule = rules.find((r) => r && r.capability === capability && r.effect === 'allow' && Array.isArray(r.match));
71
+ if (!rule) {
72
+ rule = { capability, match: [], effect: 'allow' };
73
+ rules.push(rule);
74
+ }
75
+ for (const m of owned) if (!rule.match.includes(m)) rule.match.push(m);
76
+ }
77
+ return rules;
78
+ }
79
+
80
+ // Remove our match entries PER-ENTRY; drop a rule only if its match becomes empty
81
+ // AND it was an allow-rule for a capability we own (never delete a user's rule).
82
+ function withoutOurRules(existing) {
83
+ const out = [];
84
+ let changed = false;
85
+ for (const r of existing) {
86
+ if (!r || r.capability == null || r.effect !== 'allow' || !Array.isArray(r.match) || !OUR_MATCHES[r.capability]) {
87
+ out.push(r); // not a rule we touch
88
+ continue;
89
+ }
90
+ const kept = r.match.filter((m) => !isOurMatch(r.capability, m));
91
+ if (kept.length !== r.match.length) changed = true;
92
+ if (kept.length > 0) out.push({ ...r, match: kept }); // user entries survive
93
+ // else: the rule was ours-only → drop it
94
+ }
95
+ return { rules: out, changed };
96
+ }
97
+
98
+ /**
99
+ * Kiro IDE 1.0's workspace-roots hash for a project path.
100
+ * sha256(forward-slash + no-trailing-slash + lowercase).slice(0,16). (D-203h)
101
+ */
102
+ export function kiroWorkspaceHash(projectRoot) {
103
+ // resolve() a RELATIVE input to absolute (defense — Kiro keys on the absolute
104
+ // workspace root; a relative path would hash to a dir Kiro never reads, review
105
+ // M1). An ALREADY-absolute path (drive-letter `C:\…` or POSIX `/…`) is passed
106
+ // through verbatim — so a Windows path stays correct even when this runs on a
107
+ // POSIX CI (resolve() there would wrongly prepend cwd to `c:/…`). NOTE: only
108
+ // ordinary drive-letter paths are ground-truth-verified (D-203h); UNC/extended-
109
+ // length are untested.
110
+ const p = String(projectRoot);
111
+ const isAbsolute = /^[a-zA-Z]:[\\/]/.test(p) || /^[\\/]/.test(p);
112
+ const abs = isAbsolute ? p : resolve(p);
113
+ const norm = abs.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
114
+ return createHash('sha256').update(norm).digest('hex').slice(0, 16);
115
+ }
116
+
117
+ function permissionsPath(projectRoot, env) {
118
+ const home = env.USERPROFILE || env.HOME || homedir();
119
+ return join(home, '.kiro', 'workspace-roots', kiroWorkspaceHash(projectRoot), 'permissions.yaml');
120
+ }
121
+
122
+ // Parse an existing permissions.yaml → its rules array. A missing file → []. A
123
+ // MALFORMED file → { malformed: true } so the caller REFUSES to overwrite it
124
+ // (mirrors kiro-trusted-commands' don't-clobber-a-corrupt-file posture, review
125
+ // M3 — this writes to the user's HOME; never destroy content we couldn't parse).
126
+ function readRules(path) {
127
+ if (!existsSync(path)) return { rules: [], existed: false };
128
+ let parsed;
129
+ try {
130
+ parsed = yaml.load(readFileSync(path, 'utf8'));
131
+ } catch {
132
+ return { rules: [], existed: true, malformed: true };
133
+ }
134
+ // a parse that yields a non-object (e.g. a stray scalar) is also unsafe to clobber.
135
+ if (parsed != null && typeof parsed !== 'object') return { rules: [], existed: true, malformed: true };
136
+ const rules = parsed && Array.isArray(parsed.rules) ? parsed.rules : [];
137
+ return { rules, existed: true };
138
+ }
139
+
140
+ function serialize(rules) {
141
+ // js-yaml dump; flow-style for the compact match arrays Kiro uses is optional —
142
+ // block style is equally valid YAML and is what Kiro itself wrote.
143
+ return yaml.dump({ rules }, { lineWidth: -1, noRefs: true });
144
+ }
145
+
146
+ export function installKiroPermissions({ projectRoot, env = process.env } = {}) {
147
+ if (!projectRoot) throw new Error('installKiroPermissions: projectRoot is required');
148
+ const path = permissionsPath(projectRoot, env);
149
+ const { rules: existing, malformed } = readRules(path);
150
+ if (malformed) return { action: 'skipped', changed: false, path, reason: 'malformed-permissions-yaml' };
151
+ const merged = withOurRules(existing); // per-entry merge — preserves order + user entries
152
+ const serialized = serialize(merged);
153
+
154
+ const prior = existsSync(path) ? readFileSync(path, 'utf8') : null;
155
+ let changed = false;
156
+ if (prior !== serialized) {
157
+ mkdirSync(dirname(path), { recursive: true });
158
+ writeFileSync(path, serialized, 'utf8');
159
+ changed = true;
160
+ }
161
+ return { action: 'installed', changed, path };
162
+ }
163
+
164
+ export function uninstallKiroPermissions({ projectRoot, env = process.env } = {}) {
165
+ if (!projectRoot) throw new Error('uninstallKiroPermissions: projectRoot is required');
166
+ const path = permissionsPath(projectRoot, env);
167
+ if (!existsSync(path)) return { action: 'uninstalled', changed: false };
168
+ const { rules: existing } = readRules(path);
169
+ const { rules: kept, changed: removed } = withoutOurRules(existing); // per-entry removal
170
+ if (!removed) return { action: 'uninstalled', changed: false }; // none of ours present
171
+ // preserve the user's rules + co-located entries; write back without ours (never
172
+ // delete the file — it may hold the user's own + Kiro's migration data).
173
+ writeFileSync(path, serialize(kept), 'utf8');
174
+ return { action: 'uninstalled', changed: true };
175
+ }
@@ -0,0 +1,96 @@
1
+ // kiro-skills.mjs — install the kit's memory skills into Kiro (Task 50.I, skills leg).
2
+ //
3
+ // Kiro's skill surface is Claude-Code-style <name>/SKILL.md with YAML frontmatter
4
+ // (verified from real installs: .kiro/skills/<name>/SKILL.md project-tier,
5
+ // ~/.kiro/skills/<name>/SKILL.md user-tier). The kit already ships memory-search
6
+ // + memory-write as SKILL.md under template/.claude/skills/ — they port directly.
7
+ // The only transform: drop the Claude-Code-specific frontmatter keys Kiro doesn't
8
+ // honor (`context`, `allowed-tools`) while keeping `name` + `description` + the
9
+ // instruction body. The cmk MCP tools the skills reference are wired by the MCP
10
+ // leg, so the skill body works unchanged.
11
+ //
12
+ // Public surface:
13
+ // installKiroSkills({ projectRoot, templateDir? }) → { action, changed, skills }
14
+ // uninstallKiroSkills({ projectRoot }) → { action, changed, skills }
15
+
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { resolveTemplateDir } from './install.mjs';
19
+
20
+ // The kit skills that map to Kiro. (memory-search + memory-write — the two the
21
+ // kit scaffolds for Claude Code.)
22
+ const KIT_SKILLS = ['memory-search', 'memory-write'];
23
+
24
+ // Frontmatter keys Kiro does NOT use — dropped on translation. Everything else
25
+ // in the frontmatter (name, description) + the whole body is preserved.
26
+ const DROP_KEYS = new Set(['context', 'allowed-tools']);
27
+
28
+ export function installKiroSkills({ projectRoot, templateDir } = {}) {
29
+ if (!projectRoot) throw new Error('installKiroSkills: projectRoot is required');
30
+ const tpl = templateDir || resolveTemplateDir();
31
+ const skillsRoot = join(projectRoot, '.kiro', 'skills');
32
+
33
+ let changed = false;
34
+ const installed = [];
35
+ for (const name of KIT_SKILLS) {
36
+ const src = join(tpl, '.claude', 'skills', name, 'SKILL.md');
37
+ if (!existsSync(src)) continue; // skill not in this template — skip, don't fail
38
+ const translated = translateFrontmatter(readFileSync(src, 'utf8'));
39
+ const destDir = join(skillsRoot, name);
40
+ const dest = join(destDir, 'SKILL.md');
41
+
42
+ const existing = existsSync(dest) ? readFileSync(dest, 'utf8') : null;
43
+ if (existing !== translated) {
44
+ mkdirSync(destDir, { recursive: true });
45
+ writeFileSync(dest, translated, 'utf8');
46
+ changed = true;
47
+ }
48
+ installed.push(name);
49
+ }
50
+ return { action: 'installed', changed, skills: installed };
51
+ }
52
+
53
+ export function uninstallKiroSkills({ projectRoot } = {}) {
54
+ if (!projectRoot) throw new Error('uninstallKiroSkills: projectRoot is required');
55
+ const skillsRoot = join(projectRoot, '.kiro', 'skills');
56
+ let changed = false;
57
+ const removed = [];
58
+ for (const name of KIT_SKILLS) {
59
+ const dir = join(skillsRoot, name);
60
+ if (existsSync(dir)) {
61
+ rmSync(dir, { recursive: true, force: true });
62
+ changed = true;
63
+ removed.push(name);
64
+ }
65
+ }
66
+ return { action: 'uninstalled', changed, skills: removed };
67
+ }
68
+
69
+ // ── internal ─────────────────────────────────────────────────────────────────
70
+
71
+ // Drop the Claude-Code-only frontmatter keys, keep the rest of the frontmatter +
72
+ // the entire body. Operates on the leading `---` … `---` block only; the body is
73
+ // byte-preserved. Multi-line values (e.g. a wrapped description) are kept whole —
74
+ // a dropped key's continuation lines (indented) are dropped with it.
75
+ function translateFrontmatter(content) {
76
+ const m = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
77
+ if (!m) return content; // no frontmatter — pass through unchanged
78
+ const [, fm, body] = m;
79
+
80
+ const lines = fm.split('\n');
81
+ const kept = [];
82
+ let dropping = false;
83
+ for (const line of lines) {
84
+ const keyMatch = line.match(/^([A-Za-z0-9_-]+):/);
85
+ if (keyMatch) {
86
+ // a new top-level key — decide whether to drop it (+ its continuation)
87
+ dropping = DROP_KEYS.has(keyMatch[1]);
88
+ if (!dropping) kept.push(line);
89
+ } else if (!dropping) {
90
+ // continuation line of a kept key (indented / blank) — preserve
91
+ kept.push(line);
92
+ }
93
+ // else: continuation of a dropped key — skip
94
+ }
95
+ return `---\n${kept.join('\n')}\n---\n${body}`;
96
+ }
@@ -0,0 +1,366 @@
1
+ // kiro-transcript.mjs — the Kiro session-transcript adapter (Task 50.H).
2
+ //
3
+ // Resolves the D-180 "highest unverified risk": Kiro stores transcripts
4
+ // differently from Claude Code, and the kit's capture path hardcoded the
5
+ // Claude-Code touchpoints (~/.claude/projects/<slug>/<session>.jsonl, JSONL).
6
+ // Verified on a REAL Kiro install (D-180): Kiro is a VS Code fork; per-session
7
+ // JSON lives at
8
+ // %APPDATA%/Kiro/User/globalStorage/kiro.kiroagent/workspace-sessions/
9
+ // <base64url(workspacePath)>/<sessionId>.json
10
+ // with a `history[]` of { message: { role, content: [{type:'text', text}] } }
11
+ // plus a sibling `sessions.json` index.
12
+ //
13
+ // This module is the per-agent transcript adapter the cross-agent seam needs:
14
+ // it turns Kiro's session JSON into the {role, text} turns the kit's capture
15
+ // path consumes, and resolves the workspace→dir key. Pure + defensive — a
16
+ // malformed/partial session returns [] rather than throwing (a capture hook
17
+ // must never crash the agent).
18
+ //
19
+ // Public surface:
20
+ // parseKiroSessionHistory(jsonText) → [{role, text}] (ordered turns, IDE schema)
21
+ // parseKiroCliSession(jsonText) → {assistantText} (kiro-cli schema, D-199)
22
+ // readKiroCliTurn({projectRoot, env}) → {userText, assistantText} (~/.kiro/sessions/cli)
23
+ // workspaceKeyForPath(workspacePath) → string (the base64url dir key)
24
+ // parseKiroIdeV1Messages(jsonlText) → {userText, assistantText} (IDE 1.0 messages.jsonl)
25
+ // readKiroIdeV1Turn({projectRoot, env}) → {userText, assistantText} (~/.kiro/sessions/<hash>/sess_*)
26
+ // readKiroTurn({projectRoot, env}) → {userText, assistantText} (IDE-0.x → CLI → IDE-1.0)
27
+
28
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
29
+ import { homedir } from 'node:os';
30
+ import { join } from 'node:path';
31
+
32
+ /**
33
+ * Parse a Kiro session JSON into ordered {role, text} turns.
34
+ * @param {string} jsonText raw contents of a <sessionId>.json file
35
+ * @returns {{role:string, text:string}[]}
36
+ */
37
+ export function parseKiroSessionHistory(jsonText) {
38
+ let session;
39
+ try {
40
+ session = JSON.parse(jsonText);
41
+ } catch {
42
+ return [];
43
+ }
44
+ if (!session || !Array.isArray(session.history)) return [];
45
+
46
+ const turns = [];
47
+ for (const item of session.history) {
48
+ const msg = item && item.message;
49
+ if (!msg || typeof msg.role !== 'string') continue;
50
+ const text = extractText(msg.content);
51
+ if (text !== '') turns.push({ role: msg.role, text });
52
+ }
53
+ return turns;
54
+ }
55
+
56
+ /**
57
+ * Parse a kiro-cli session JSON into the latest assistant turn text.
58
+ *
59
+ * The kiro-CLI session schema is DIFFERENT from the IDE's (D-199 gate finding —
60
+ * primary-source verified on a real `~/.kiro/sessions/cli/<uuid>.json`): there is
61
+ * NO `history[]`. Instead the per-turn assistant text lives at
62
+ * session_state.conversation_metadata.user_turn_metadatas[].result.Ok.content[].data
63
+ * (each content part is `{ kind, data }`, `kind === 'text'`). The user's prompt
64
+ * text is NOT stored verbatim (only `user_prompt_length`), so the CLI path yields
65
+ * the ASSISTANT text only — which is all captureTurn's extractTurnText needs.
66
+ *
67
+ * @param {string} jsonText raw contents of a kiro-cli <uuid>.json file
68
+ * @returns {{assistantText:string}}
69
+ */
70
+ export function parseKiroCliSession(jsonText) {
71
+ let session;
72
+ try {
73
+ session = JSON.parse(jsonText);
74
+ } catch {
75
+ return { assistantText: '' };
76
+ }
77
+ const turns = session?.session_state?.conversation_metadata?.user_turn_metadatas;
78
+ if (!Array.isArray(turns) || turns.length === 0) return { assistantText: '' };
79
+
80
+ // the LAST turn is the most recent; read its assistant text from result.Ok.content[].
81
+ const last = turns[turns.length - 1];
82
+ const content = last?.result?.Ok?.content;
83
+ if (!Array.isArray(content)) return { assistantText: '' };
84
+ const assistantText = content
85
+ .filter((part) => part && part.kind === 'text' && typeof part.data === 'string')
86
+ .map((part) => part.data)
87
+ .join('\n');
88
+ return { assistantText };
89
+ }
90
+
91
+ /**
92
+ * Read the latest assistant turn from a kiro-CLI session for a project. The CLI
93
+ * stores sessions at ~/.kiro/sessions/cli/<uuid>.json, each carrying its own
94
+ * `cwd` + `updated_at`; we pick the most-recent file whose `cwd` matches the
95
+ * project root. Pure + defensive (a capture hook must never crash the session).
96
+ * @param {{projectRoot:string, env?:object}} args
97
+ * @returns {{userText:string, assistantText:string}}
98
+ */
99
+ export function readKiroCliTurn({ projectRoot, env = process.env } = {}) {
100
+ const empty = { userText: '', assistantText: '' };
101
+ try {
102
+ if (!projectRoot) return empty;
103
+ const home = env.USERPROFILE || env.HOME || homedir();
104
+ const cliDir = join(home, '.kiro', 'sessions', 'cli');
105
+ if (!existsSync(cliDir)) return empty;
106
+
107
+ // Compare cwd by NORMALIZED separators + case, NOT path.resolve(): both
108
+ // projectRoot and json.cwd are already absolute, same-machine, same-form
109
+ // paths (the hook's cwd and the session's recorded cwd). resolve() would
110
+ // re-root a value against the current cwd if it ever looked relative — a real
111
+ // hazard. ASSUMPTION: both sides are absolute drive-letter or matching-form
112
+ // paths; an extended-length (\\?\) or `~` form on one side only won't match
113
+ // (a missed match → empty capture, never a crash). Full-string equality, not
114
+ // a prefix test — so `C:\Temp\proj` and `C:\Temp\proj-2` correctly differ.
115
+ const norm = (p) => p.replace(/[\\/]+/g, '/').replace(/\/+$/, '').toLowerCase();
116
+ const want = norm(projectRoot);
117
+ const files = readdirSync(cliDir).filter((f) => f.endsWith('.json'));
118
+ let best = null;
119
+ let bestStamp = null; // [epochMs, filename] for a deterministic tie-break
120
+ for (const f of files) {
121
+ let json;
122
+ try {
123
+ json = JSON.parse(readFileSync(join(cliDir, f), 'utf8'));
124
+ } catch {
125
+ continue;
126
+ }
127
+ if (!json?.cwd || norm(json.cwd) !== want) continue;
128
+ // Parse updated_at to epoch ms for a numeric compare — robust to ISO
129
+ // precision/offset drift (a future kiro-cli format change won't silently
130
+ // mis-order). Unparseable → 0, so the filename tie-break still orders it.
131
+ const epoch = Date.parse(json.updated_at) || 0;
132
+ const stamp = [epoch, f];
133
+ if (bestStamp === null || stamp[0] > bestStamp[0] || (stamp[0] === bestStamp[0] && stamp[1] > bestStamp[1])) {
134
+ bestStamp = stamp;
135
+ best = json;
136
+ }
137
+ }
138
+ if (!best) return empty;
139
+ const { assistantText } = parseKiroCliSession(JSON.stringify(best));
140
+ return { userText: '', assistantText };
141
+ } catch {
142
+ return empty;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Parse a Kiro IDE 1.0 messages.jsonl (D-203g) into the latest user + assistant
148
+ * text. IDE 1.0 moved session storage to ~/.kiro/sessions/<hash>/sess_<uuid>/ —
149
+ * a JSON-Lines `messages.jsonl`: one `{id, timestamp, payload:{type, content}}` per
150
+ * line; `payload.type ∈ {user, assistant, tool_call, tool_result, turn_start/end,
151
+ * ContextualHookInvoked, …}`; `payload.content` is the message text (string) for
152
+ * user/assistant. We take the LAST user + LAST assistant content. Primary-source
153
+ * verified on a real IDE-1.0 session.
154
+ * @param {string} jsonlText raw contents of a messages.jsonl
155
+ * @returns {{userText:string, assistantText:string}}
156
+ */
157
+ export function parseKiroIdeV1Messages(jsonlText) {
158
+ const empty = { userText: '', assistantText: '' };
159
+ if (typeof jsonlText !== 'string') return empty;
160
+ let userText = '';
161
+ let assistantText = '';
162
+ for (const line of jsonlText.split('\n')) {
163
+ if (line.trim() === '') continue;
164
+ let msg;
165
+ try {
166
+ msg = JSON.parse(line);
167
+ } catch {
168
+ continue; // a malformed line never crashes the whole read
169
+ }
170
+ const type = msg?.payload?.type;
171
+ if (type !== 'user' && type !== 'assistant') continue;
172
+ const text = ideV1ContentText(msg?.payload?.content);
173
+ if (text === '') continue;
174
+ if (type === 'user') userText = text; // keep the LAST one
175
+ else assistantText = text;
176
+ }
177
+ return { userText, assistantText };
178
+ }
179
+
180
+ // IDE 1.0 payload.content is a STRING for plain messages (verified on a real
181
+ // session), but a multi-part message (with images/documents) MAY serialize it as
182
+ // an array of typed parts like IDE-0.x — so handle BOTH (review I1): a string is
183
+ // returned as-is; an array joins the `text`-typed parts (the `{type:'text', text}`
184
+ // shape, like extractText). Anything else → '' (drop, never crash). Defensive so a
185
+ // multi-part assistant reply isn't silently dropped → capture-nothing recurrence.
186
+ function ideV1ContentText(content) {
187
+ if (typeof content === 'string') return content;
188
+ if (Array.isArray(content)) {
189
+ return content
190
+ .filter((part) => part && part.type === 'text' && typeof part.text === 'string')
191
+ .map((part) => part.text)
192
+ .join('\n');
193
+ }
194
+ return '';
195
+ }
196
+
197
+ /**
198
+ * Read the latest turn from a Kiro IDE 1.0 session (D-203g). Scans
199
+ * ~/.kiro/sessions/<workspace-hash>/sess_<uuid>/, matching session.json's
200
+ * `workspacePaths` to projectRoot (no hash-reversing), picking the most-recently-
201
+ * modified messages.jsonl. Pure + defensive (a capture hook must never crash).
202
+ * @param {{projectRoot:string, env?:object}} args
203
+ * @returns {{userText:string, assistantText:string}}
204
+ */
205
+ export function readKiroIdeV1Turn({ projectRoot, env = process.env } = {}) {
206
+ const empty = { userText: '', assistantText: '' };
207
+ try {
208
+ if (!projectRoot) return empty;
209
+ const home = env.USERPROFILE || env.HOME || homedir();
210
+ const sessionsRoot = join(home, '.kiro', 'sessions');
211
+ if (!existsSync(sessionsRoot)) return empty;
212
+
213
+ const norm = (p) => p.replace(/[\\/]+/g, '/').replace(/\/+$/, '').toLowerCase();
214
+ const want = norm(projectRoot);
215
+
216
+ // walk <hash>/sess_*/ dirs; match by session.json.workspacePaths; pick latest
217
+ // messages.jsonl by mtime, tie-broken by path (review M1 — mirror the CLI/0.x
218
+ // readers' deterministic [stamp, path] tie-break so an equal mtime isn't
219
+ // decided by readdir order).
220
+ let best = null; // [mtimeMs, messagesPath]
221
+ for (const hash of readdirSync(sessionsRoot)) {
222
+ const hashDir = join(sessionsRoot, hash);
223
+ let sessDirs;
224
+ try {
225
+ sessDirs = readdirSync(hashDir).filter((d) => d.startsWith('sess_'));
226
+ } catch {
227
+ // a non-directory entry at sessions/ root → readdirSync throws ENOTDIR;
228
+ // skip it. (The `cli/` sibling IS a dir but has no sess_* children, so it
229
+ // falls out of the filter below — both cases are handled, never crash.)
230
+ continue;
231
+ }
232
+ for (const sd of sessDirs) {
233
+ const dir = join(hashDir, sd);
234
+ const metaPath = join(dir, 'session.json');
235
+ const msgPath = join(dir, 'messages.jsonl');
236
+ if (!existsSync(metaPath) || !existsSync(msgPath)) continue;
237
+ let meta;
238
+ try {
239
+ meta = JSON.parse(readFileSync(metaPath, 'utf8'));
240
+ } catch {
241
+ continue;
242
+ }
243
+ const paths = Array.isArray(meta?.workspacePaths) ? meta.workspacePaths : [];
244
+ if (!paths.some((p) => typeof p === 'string' && norm(p) === want)) continue;
245
+ let mtimeMs = 0;
246
+ try {
247
+ mtimeMs = statSync(msgPath).mtimeMs;
248
+ } catch {
249
+ /* keep 0 */
250
+ }
251
+ if (
252
+ best === null ||
253
+ mtimeMs > best[0] ||
254
+ (mtimeMs === best[0] && msgPath > best[1]) // deterministic tie-break (M1)
255
+ ) {
256
+ best = [mtimeMs, msgPath];
257
+ }
258
+ }
259
+ }
260
+ if (!best) return empty;
261
+ return parseKiroIdeV1Messages(readFileSync(best[1], 'utf8'));
262
+ } catch {
263
+ return empty;
264
+ }
265
+ }
266
+
267
+ // Join the text of all text-type content parts; ignore tool/other blocks.
268
+ // Kiro's content is always an array of typed parts (verified on a real install);
269
+ // a non-array is treated as "no text" rather than guessed at.
270
+ function extractText(content) {
271
+ if (!Array.isArray(content)) return '';
272
+ return content
273
+ .filter((part) => part && part.type === 'text' && typeof part.text === 'string')
274
+ .map((part) => part.text)
275
+ .join('\n');
276
+ }
277
+
278
+ /**
279
+ * Encode a workspace path to Kiro's workspace-sessions directory key.
280
+ * EXACT scheme verified on a real install: standard base64, then +→-, /→_, and
281
+ * the `=` padding → `_` (NOT stripped — Kiro keeps the padding as underscores).
282
+ * @param {string} workspacePath e.g. 'c:\\Projects\\demo'
283
+ * @returns {string}
284
+ */
285
+ export function workspaceKeyForPath(workspacePath) {
286
+ return Buffer.from(workspacePath, 'utf8')
287
+ .toString('base64')
288
+ .replace(/\+/g, '-')
289
+ .replace(/\//g, '_')
290
+ .replace(/=/g, '_');
291
+ }
292
+
293
+ /**
294
+ * Read the latest user+assistant turn from Kiro's transcript for a project.
295
+ *
296
+ * Composes the verified pieces (probe P-CJYGTQYR + D-180):
297
+ * - the globalStorage dir is given to the hook via env CONTINUE_GLOBAL_DIR
298
+ * (= …/Kiro/User/globalStorage/kiro.kiroagent), so we don't guess the path;
299
+ * - the per-project dir is workspace-sessions/<base64url(projectRoot)>;
300
+ * - the most-recent <sessionId>.json (by dateCreated, else mtime) is the live
301
+ * session; its history[] is parsed to the latest user + assistant text.
302
+ *
303
+ * Pure + defensive: any missing dir / absent env / parse error returns empty
304
+ * strings, never throws (a capture hook must never crash the Kiro session).
305
+ *
306
+ * @param {{projectRoot:string, env?:object}} args
307
+ * @returns {{userText:string, assistantText:string}}
308
+ */
309
+ // The non-(IDE-0.x) fallback chain: kiro-CLI (~/.kiro/sessions/cli, D-199) → IDE
310
+ // 1.0 (~/.kiro/sessions/<hash>/sess_*/messages.jsonl, D-203g). Tried when the
311
+ // legacy IDE-0.x globalStorage path yields nothing. Returns the first non-empty.
312
+ function readKiroFallbackTurn({ projectRoot, env }) {
313
+ const cli = readKiroCliTurn({ projectRoot, env });
314
+ if (cli.assistantText || cli.userText) return cli;
315
+ return readKiroIdeV1Turn({ projectRoot, env });
316
+ }
317
+
318
+ export function readKiroTurn({ projectRoot, env = process.env } = {}) {
319
+ const empty = { userText: '', assistantText: '' };
320
+ try {
321
+ const globalDir = env.CONTINUE_GLOBAL_DIR;
322
+ // No IDE-0.x globalStorage (the CLI/IDE-1.0 don't set CONTINUE_GLOBAL_DIR) →
323
+ // the fallback chain (kiro-CLI then IDE-1.0). The legacy IDE-0.x path stays
324
+ // PRIMARY when its env+dir are present, so an 0.x user is unaffected.
325
+ if (!globalDir || !projectRoot) return readKiroFallbackTurn({ projectRoot, env });
326
+ const wsDir = join(globalDir, 'workspace-sessions', workspaceKeyForPath(projectRoot));
327
+ if (!existsSync(wsDir)) return readKiroFallbackTurn({ projectRoot, env });
328
+
329
+ // pick the most-recent session file (by dateCreated in the JSON, else mtime).
330
+ const files = readdirSync(wsDir).filter((f) => f.endsWith('.json') && f !== 'sessions.json');
331
+ if (files.length === 0) return readKiroFallbackTurn({ projectRoot, env });
332
+
333
+ // Sort files for a DETERMINISTIC pick (review M2): primary by dateCreated
334
+ // (desc), secondary by filename (desc) so an equal-stamp tie isn't decided by
335
+ // readdir order. The first after sort is the most-recent session.
336
+ let best = null;
337
+ let bestKey = null; // [stamp, filename]
338
+ for (const f of files) {
339
+ let json;
340
+ try {
341
+ json = JSON.parse(readFileSync(join(wsDir, f), 'utf8'));
342
+ } catch {
343
+ continue;
344
+ }
345
+ const key = [Number(json.dateCreated) || 0, f];
346
+ if (bestKey === null || key[0] > bestKey[0] || (key[0] === bestKey[0] && key[1] > bestKey[1])) {
347
+ bestKey = key;
348
+ best = json;
349
+ }
350
+ }
351
+ if (!best || !Array.isArray(best.history)) return readKiroFallbackTurn({ projectRoot, env });
352
+
353
+ const turns = parseKiroSessionHistory(JSON.stringify(best));
354
+ const lastUser = [...turns].reverse().find((t) => t.role === 'user');
355
+ const lastAssistant = [...turns].reverse().find((t) => t.role === 'assistant');
356
+ // IDE-0.x session present but no text → fall through (a mixed install where the
357
+ // same project was used from another surface).
358
+ if (!lastAssistant?.text && !lastUser?.text) return readKiroFallbackTurn({ projectRoot, env });
359
+ return {
360
+ userText: lastUser?.text || '',
361
+ assistantText: lastAssistant?.text || '',
362
+ };
363
+ } catch {
364
+ return empty;
365
+ }
366
+ }