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.
- package/dist/agent/runtime.js +2 -0
- package/dist/checkpoints/store.d.ts +13 -0
- package/dist/checkpoints/store.js +39 -0
- package/dist/cli/repl.js +16 -2
- package/dist/tools/lsp/diagnostics.d.ts +50 -0
- package/dist/tools/lsp/diagnostics.js +109 -3
- package/dist/tools/registry.js +3 -1
- package/package.json +1 -1
package/dist/agent/runtime.js
CHANGED
|
@@ -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.
|
|
9
|
-
*
|
|
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
|
+
});
|
package/dist/tools/registry.js
CHANGED
|
@@ -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);
|