@lh8ppl/claude-memory-kit 0.3.5 → 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.
- package/README.md +6 -0
- package/bin/cmk-guard-memory.mjs +57 -0
- package/package.json +3 -2
- package/src/agent-profile.mjs +115 -0
- package/src/agent-profiles.mjs +118 -0
- package/src/auto-persona.mjs +4 -1
- package/src/compress-session.mjs +13 -1
- package/src/config-core.mjs +7 -9
- package/src/decisions-journal.mjs +71 -3
- package/src/doctor.mjs +86 -4
- package/src/guard-memory.mjs +151 -0
- package/src/import-anthropic-memory.mjs +15 -1
- package/src/inject-context.mjs +34 -3
- package/src/install-agent.mjs +220 -0
- package/src/install-kiro.mjs +287 -0
- package/src/install.mjs +16 -3
- package/src/kiro-cli-agent.mjs +270 -0
- package/src/kiro-constants.mjs +19 -0
- package/src/kiro-hook-bin.mjs +105 -0
- package/src/kiro-hook-command.mjs +67 -0
- package/src/kiro-hook-dispatch.mjs +115 -0
- package/src/kiro-ide-hooks.mjs +219 -0
- package/src/kiro-permissions.mjs +175 -0
- package/src/kiro-skills.mjs +96 -0
- package/src/kiro-transcript.mjs +366 -0
- package/src/kiro-trusted-commands.mjs +130 -0
- package/src/managed-block.mjs +138 -0
- package/src/memory-write.mjs +23 -8
- package/src/mutate-agent-config.mjs +243 -0
- package/src/read-json.mjs +43 -0
- package/src/reindex.mjs +15 -2
- package/src/repair.mjs +39 -3
- package/src/result-shapes.mjs +8 -0
- package/src/review-queue.mjs +3 -0
- package/src/scratchpad.mjs +12 -2
- package/src/search.mjs +12 -5
- package/src/semantic-backend.mjs +7 -9
- package/src/settings-hooks.mjs +12 -2
- package/src/subcommands.mjs +360 -27
- package/src/tier-paths.mjs +48 -1
- package/src/weekly-curate.mjs +6 -2
- package/template/.claude/skills/memory-search/SKILL.md +14 -1
- package/template/.claude/skills/memory-write/SKILL.md +37 -1
- 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
|
+
}
|