klyro 1.0.18 → 1.0.20

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.
@@ -1082,6 +1082,7 @@ export async function run(opts, deps) {
1082
1082
  await snapshot(opts.cwd, [...fileEditCounts.keys()].slice(-20), {
1083
1083
  ...(sessionId !== undefined ? { sessionId } : {}),
1084
1084
  eventId: call.id,
1085
+ transcript,
1085
1086
  });
1086
1087
  }
1087
1088
  catch { /* ignore */ }
@@ -1176,6 +1177,7 @@ export async function run(opts, deps) {
1176
1177
  await snapshot(opts.cwd, [fileChanged.path], {
1177
1178
  ...(sessionId !== undefined ? { sessionId } : {}),
1178
1179
  eventId: call.id,
1180
+ transcript,
1179
1181
  });
1180
1182
  }
1181
1183
  catch { /* ignore */ }
@@ -14,6 +14,7 @@
14
14
  export interface SnapshotOptions {
15
15
  sessionId?: string;
16
16
  eventId?: string;
17
+ transcript?: unknown[];
17
18
  }
18
19
  /** Checkpoint meta on disk — sessionId/eventId are absent on old metas. */
19
20
  export interface CheckpointMeta {
@@ -23,6 +24,7 @@ export interface CheckpointMeta {
23
24
  ts: number;
24
25
  sessionId?: string;
25
26
  eventId?: string;
27
+ transcriptHash?: string;
26
28
  }
27
29
  export declare function snapshot(cwd: string, files: string[], opts?: SnapshotOptions): Promise<string>;
28
30
  export declare function listCheckpoints(cwd: string): Promise<string[]>;
@@ -37,6 +39,17 @@ export interface CheckpointInfo {
37
39
  export declare function listCheckpointInfo(cwd: string): Promise<CheckpointInfo[]>;
38
40
  /** File paths a snapshot would restore (for `/rewind <n> preview`). */
39
41
  export declare function snapshotFiles(cwd: string, id: string): Promise<string[]>;
42
+ /**
43
+ * Transcript captured at a checkpoint, or null when the snapshot predates
44
+ * transcript capture or the payload is missing/corrupt.
45
+ *
46
+ * The stored hash is verified on read: a checkpoint is only ever trusted for
47
+ * restoring the conversation when its transcript.json still matches the
48
+ * transcriptHash recorded in .meta.json. A mismatch (partial write, manual
49
+ * edit, tampering) yields null so the caller can report "conversation
50
+ * untouched" instead of rewinding onto unverified content.
51
+ */
52
+ export declare function readSnapshotTranscript(cwd: string, id: string): Promise<unknown[] | null>;
40
53
  export declare function diff(cwd: string, id?: string): Promise<string>;
41
54
  export declare function undo(cwd: string, n?: number): Promise<void>;
42
55
  export declare function rewind(cwd: string): Promise<void>;
@@ -97,6 +97,14 @@ export async function snapshot(cwd, files, opts) {
97
97
  meta.sessionId = opts.sessionId;
98
98
  if (opts?.eventId !== undefined)
99
99
  meta.eventId = opts.eventId;
100
+ if (opts?.transcript) {
101
+ const transcriptPath = path.join(dest, 'transcript.json');
102
+ await fs.writeFile(transcriptPath, JSON.stringify(opts.transcript), 'utf-8');
103
+ lockDown(transcriptPath, 0o600);
104
+ await fsyncFile(transcriptPath);
105
+ const hash = crypto.createHash('sha256').update(JSON.stringify(opts.transcript)).digest('hex');
106
+ meta.transcriptHash = hash;
107
+ }
100
108
  await fs.writeFile(metaTmp, JSON.stringify(meta, null, 2));
101
109
  lockDown(metaTmp, 0o600);
102
110
  await fsyncFile(metaTmp);
@@ -208,6 +216,35 @@ export async function snapshotFiles(cwd, id) {
208
216
  return [];
209
217
  }
210
218
  }
219
+ /**
220
+ * Transcript captured at a checkpoint, or null when the snapshot predates
221
+ * transcript capture or the payload is missing/corrupt.
222
+ *
223
+ * The stored hash is verified on read: a checkpoint is only ever trusted for
224
+ * restoring the conversation when its transcript.json still matches the
225
+ * transcriptHash recorded in .meta.json. A mismatch (partial write, manual
226
+ * edit, tampering) yields null so the caller can report "conversation
227
+ * untouched" instead of rewinding onto unverified content.
228
+ */
229
+ export async function readSnapshotTranscript(cwd, id) {
230
+ try {
231
+ const dir = path.join(ckptDir(cwd), id);
232
+ const meta = JSON.parse(await fs.readFile(path.join(dir, '.meta.json'), 'utf-8'));
233
+ const raw = await fs.readFile(path.join(dir, 'transcript.json'), 'utf-8');
234
+ const parsed = JSON.parse(raw);
235
+ if (!Array.isArray(parsed))
236
+ return null;
237
+ if (typeof meta.transcriptHash === 'string') {
238
+ const actual = crypto.createHash('sha256').update(raw).digest('hex');
239
+ if (actual !== meta.transcriptHash)
240
+ return null;
241
+ }
242
+ return parsed;
243
+ }
244
+ catch {
245
+ return null;
246
+ }
247
+ }
211
248
  export async function diff(cwd, id) {
212
249
  const ckpts = await listCheckpoints(cwd);
213
250
  const target = id ?? ckpts[ckpts.length - 1];
@@ -232,6 +269,8 @@ export async function undo(cwd, n = 1) {
232
269
  const metaRaw = await fs.readFile(path.join(srcDir, '.meta.json'), 'utf-8');
233
270
  const meta = JSON.parse(metaRaw);
234
271
  // Restore modified/created files to their snapshotted content…
272
+ // Transcript restore is handled by caller via transcript snapshot — read via
273
+ // readSnapshotTranscript() if the caller wants conversation rewind.
235
274
  for (const f of meta.files) {
236
275
  const src = containedPath(cwd, srcDir, f);
237
276
  const dest = src ? containedPath(cwd, cwd, f) : null;
package/dist/cli/repl.js CHANGED
@@ -1261,6 +1261,20 @@ export async function startRepl(opts = {}) {
1261
1261
  }
1262
1262
  await undo(cwd, n);
1263
1263
  let text = `Rewound to checkpoint ${n} (${target.id.slice(0, 12)}, ${target.files} file(s))`;
1264
+ // If transcript was captured for this checkpoint, restore it into the
1265
+ // active REPL session so /rewind full also reverts the conversation.
1266
+ const { readSnapshotTranscript } = await import('../checkpoints/store.js');
1267
+ const raw = await readSnapshotTranscript(cwd, target.id);
1268
+ // readSnapshotTranscript returns `unknown[]`; verify minimal Message shape
1269
+ // before giving it to adoptTranscript to avoid runtime type errors.
1270
+ const isMessage = (v) => !!v && typeof v === 'object' && 'role' in v && 'content' in v;
1271
+ if (Array.isArray(raw) && raw.length > 0 && raw.every(isMessage)) {
1272
+ sessionMessages = adoptTranscript(raw);
1273
+ text += '\nConversation restored from checkpoint transcript.';
1274
+ }
1275
+ else if (mode === 'full') {
1276
+ text += '\nConversation rewind skipped — no transcript captured for this checkpoint.';
1277
+ }
1264
1278
  if (mode === 'summary' || mode === 'full') {
1265
1279
  const files = before.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 20);
1266
1280
  text += files.length > 0 ? `\nReverted working-tree changes:\n${files.map((f) => ` ${f}`).join('\n')}` : '\nWorking tree was clean before restore.';
@@ -2074,8 +2088,8 @@ export async function startRepl(opts = {}) {
2074
2088
  queuedAppend({ id: `ckpt-${Date.now()}`, kind: 'text', text: 'Working tree clean — nothing to checkpoint.', role: 'assistant' });
2075
2089
  }
2076
2090
  else {
2077
- const id = await snapshot(cwd, files.slice(0, 50));
2078
- queuedAppend({ id: `ckpt2-${Date.now()}`, kind: 'text', text: `checkpoint ${String(id).slice(0, 8)} — ${files.length} file(s)`, role: 'assistant' });
2091
+ const id = await snapshot(cwd, files.slice(0, 50), { transcript: sessionMessages });
2092
+ queuedAppend({ id: `ckpt2-${Date.now()}`, kind: 'text', text: `checkpoint ${String(id).slice(0, 8)} — ${files.length} file(s)` + ` (conversation archived)`, role: 'assistant' });
2079
2093
  }
2080
2094
  }
2081
2095
  catch (err) {
@@ -49,3 +49,53 @@ export declare const lspGotoDefinitionTool: import("../types.js").Tool<{
49
49
  readonly candidates: number;
50
50
  readonly note?: undefined;
51
51
  }>;
52
+ export declare const lspReferencesTool: import("../types.js").Tool<{
53
+ path: string;
54
+ line: number;
55
+ character?: number | undefined;
56
+ }, {
57
+ readonly enabled: false;
58
+ readonly references: readonly [];
59
+ readonly note: "LSP off via KLYRO_LSP=0";
60
+ } | {
61
+ readonly enabled: true;
62
+ readonly references: readonly [];
63
+ readonly note: `unreadable file: ${string}`;
64
+ } | {
65
+ readonly enabled: true;
66
+ readonly references: readonly [];
67
+ readonly note: "no identifier under cursor";
68
+ } | {
69
+ readonly enabled: true;
70
+ readonly references: {
71
+ file: string;
72
+ line: number;
73
+ kind: string;
74
+ name: string;
75
+ }[];
76
+ readonly note?: undefined;
77
+ }>;
78
+ export declare const lspOutlineTool: import("../types.js").Tool<{
79
+ path: string;
80
+ }, {
81
+ readonly enabled: false;
82
+ readonly outline: readonly [];
83
+ readonly note: "LSP off via KLYRO_LSP=0";
84
+ } | {
85
+ readonly enabled: true;
86
+ readonly outline: readonly [];
87
+ readonly note: `unreadable file: ${string}`;
88
+ } | {
89
+ readonly enabled: true;
90
+ readonly outline: {
91
+ name: string;
92
+ kind: string;
93
+ line: number;
94
+ children: Array<{
95
+ name: string;
96
+ kind: string;
97
+ line: number;
98
+ }>;
99
+ }[];
100
+ readonly note?: undefined;
101
+ }>;
@@ -5,8 +5,10 @@
5
5
  * project has one and filters the output to the requested path (or returns
6
6
  * a capped full-project list). Other languages report "unsupported".
7
7
  * `lsp_goto_definition` resolves the identifier under (line, character) to
8
- * its definition via the regex repo-map symbol index. Set KLYRO_LSP=0 to
9
- * force both tools off.
8
+ * its definition via the regex repo-map symbol index.
9
+ * `lsp_references` finds all references to the identifier under (line, character).
10
+ * `lsp_outline` returns a hierarchical symbol outline for a file.
11
+ * Set KLYRO_LSP=0 to force all tools off.
10
12
  */
11
13
  import { spawnSync } from 'node:child_process';
12
14
  import * as fs from 'node:fs/promises';
@@ -15,7 +17,7 @@ import { z } from 'zod';
15
17
  import { defineTool } from '../types.js';
16
18
  import { safe } from '../normalize.js';
17
19
  import { resolveWithinCwd } from '../../policy/path-guard.js';
18
- import { buildRepoMap } from '../../context/repo-map.js';
20
+ import { buildRepoMap, extractSymbols } from '../../context/repo-map.js';
19
21
  function isOff() {
20
22
  return process.env.KLYRO_LSP === '0';
21
23
  }
@@ -104,3 +106,107 @@ export const lspGotoDefinitionTool = defineTool({
104
106
  return { enabled: true, location: best, candidates: hits.length };
105
107
  }),
106
108
  });
109
+ export const lspReferencesTool = defineTool({
110
+ name: 'lsp_references',
111
+ description: 'Find all references to the identifier under (line, character) via the repo symbol index',
112
+ inputSchema: z.object({ path: z.string().min(1), line: z.number().int().min(1), character: z.number().int().min(0).optional() }),
113
+ permission: 'read',
114
+ isConcurrencySafe: true,
115
+ execute: async (input, ctx) => safe(async () => {
116
+ if (isOff())
117
+ return { enabled: false, references: [], note: 'LSP off via KLYRO_LSP=0' };
118
+ const { resolved } = resolveWithinCwd(ctx.cwd, input.path, ctx.agentAllowedPaths);
119
+ let content;
120
+ try {
121
+ content = await fs.readFile(resolved, 'utf-8');
122
+ }
123
+ catch {
124
+ return { enabled: true, references: [], note: `unreadable file: ${input.path}` };
125
+ }
126
+ const lines = content.split('\n');
127
+ const lineText = lines[input.line - 1] ?? '';
128
+ const col = Math.min(input.character ?? 0, lineText.length);
129
+ const wordAt = (() => {
130
+ const isWord = (c) => /[A-Za-z0-9_$]/.test(c);
131
+ let s = col;
132
+ let e = col;
133
+ while (s > 0 && isWord(lineText[s - 1]))
134
+ s--;
135
+ while (e < lineText.length && isWord(lineText[e]))
136
+ e++;
137
+ const w = lineText.slice(s, e);
138
+ return w || null;
139
+ })();
140
+ if (!wordAt)
141
+ return { enabled: true, references: [], note: 'no identifier under cursor' };
142
+ const files = await buildRepoMap({ cwd: ctx.cwd, maxFiles: 300 });
143
+ const refs = [];
144
+ const seen = new Set();
145
+ for (const f of files) {
146
+ for (const s of f.symbols) {
147
+ if (s.name === wordAt) {
148
+ const key = `${f.path}:${s.line}:${s.name}`;
149
+ if (!seen.has(key)) {
150
+ seen.add(key);
151
+ refs.push({ file: f.path, line: s.line, kind: s.kind, name: s.name });
152
+ }
153
+ }
154
+ }
155
+ }
156
+ return { enabled: true, references: refs.slice(0, 100) };
157
+ }),
158
+ });
159
+ export const lspOutlineTool = defineTool({
160
+ name: 'lsp_outline',
161
+ description: 'Hierarchical symbol outline for a file via the repo symbol index',
162
+ inputSchema: z.object({ path: z.string().min(1) }),
163
+ permission: 'read',
164
+ isConcurrencySafe: true,
165
+ execute: async (input, ctx) => safe(async () => {
166
+ if (isOff())
167
+ return { enabled: false, outline: [], note: 'LSP off via KLYRO_LSP=0' };
168
+ const { resolved } = resolveWithinCwd(ctx.cwd, input.path, ctx.agentAllowedPaths);
169
+ // Symbol-extract this file directly rather than via buildRepoMap: the repo
170
+ // map is a whole-tree walk capped by maxFiles, so a single-file query can
171
+ // never be served from it (it would return whichever file the walk reached
172
+ // first, and the caller's path would usually be absent).
173
+ let content;
174
+ try {
175
+ content = await fs.readFile(resolved, 'utf-8');
176
+ }
177
+ catch {
178
+ return { enabled: true, outline: [], note: `unreadable file: ${input.path}` };
179
+ }
180
+ const symbols = extractSymbols(content, path.extname(resolved).toLowerCase());
181
+ // Build simple hierarchy: classes/interfaces as parents, methods/fields as children
182
+ const outline = [];
183
+ for (const sym of symbols) {
184
+ if (['class', 'interface', 'type'].includes(sym.kind)) {
185
+ outline.push({ name: sym.name, kind: sym.kind, line: sym.line, children: [] });
186
+ }
187
+ }
188
+ for (const sym of symbols) {
189
+ if (['method', 'function', 'property', 'field', 'const'].includes(sym.kind)) {
190
+ // Assign to the nearest preceding class/interface
191
+ let parentIdx = -1;
192
+ for (let i = outline.length - 1; i >= 0; i--) {
193
+ const parent = outline[i];
194
+ if (parent && parent.line < sym.line) {
195
+ parentIdx = i;
196
+ break;
197
+ }
198
+ }
199
+ if (parentIdx >= 0) {
200
+ const parentEntry = outline[parentIdx];
201
+ if (parentEntry) {
202
+ parentEntry.children.push({ name: sym.name, kind: sym.kind, line: sym.line });
203
+ }
204
+ }
205
+ else {
206
+ outline.push({ name: sym.name, kind: sym.kind, line: sym.line, children: [] });
207
+ }
208
+ }
209
+ }
210
+ return { enabled: true, outline };
211
+ }),
212
+ });
@@ -24,7 +24,7 @@ import { askUserTool } from './plan/ask-user.js';
24
24
  import { repoMapTool } from './repo-map.js';
25
25
  import { importsOfTool, importersOfTool } from './search/imports.js';
26
26
  import { findSymbolTool } from './symbols/find-symbol.js';
27
- import { lspDiagnosticsTool, lspGotoDefinitionTool } from './lsp/diagnostics.js';
27
+ import { lspDiagnosticsTool, lspGotoDefinitionTool, lspReferencesTool, lspOutlineTool } from './lsp/diagnostics.js';
28
28
  import { expandResultTool } from './expand-result.js';
29
29
  import { memoryWriteTool } from './memory-write.js';
30
30
  import { webFetchTool } from './web/web-fetch.js';
@@ -117,6 +117,8 @@ export const builtinRegistry = () => {
117
117
  r.register(findSymbolTool);
118
118
  r.register(lspDiagnosticsTool);
119
119
  r.register(lspGotoDefinitionTool);
120
+ r.register(lspReferencesTool);
121
+ r.register(lspOutlineTool);
120
122
  r.register(expandResultTool);
121
123
  r.register(memoryWriteTool);
122
124
  r.register(webFetchTool);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "description": "Klyro — autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",