@ryuu-reinzz/ryuu-cli 0.0.0-stage → 0.3.1

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 (59) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1213 -2
  4. package/package.json +45 -4
  5. package/src/agent/agent.js +1194 -0
  6. package/src/agent/approvals.js +76 -0
  7. package/src/agent/context.js +106 -0
  8. package/src/agent/planner.js +237 -0
  9. package/src/agent/worklog.js +318 -0
  10. package/src/cli/app.js +463 -0
  11. package/src/cli/commands.js +1150 -0
  12. package/src/cli/input.js +287 -0
  13. package/src/cli/markdown.js +527 -0
  14. package/src/cli/prompts.js +255 -0
  15. package/src/cli/renderer.js +2155 -0
  16. package/src/cli/settings-editor.js +280 -0
  17. package/src/cli/settings-tui.js +669 -0
  18. package/src/cli/tui.js +1737 -0
  19. package/src/cli/width.js +48 -0
  20. package/src/config/load.js +132 -0
  21. package/src/config/scaffold.js +50 -0
  22. package/src/config/settings-registry.js +495 -0
  23. package/src/config/settings.js +304 -0
  24. package/src/config/validate.js +451 -0
  25. package/src/index.js +449 -0
  26. package/src/providers/catalog.js +282 -0
  27. package/src/providers/index.js +84 -0
  28. package/src/providers/nvidia.js +645 -0
  29. package/src/providers/registry.js +104 -0
  30. package/src/services/clipboard.js +38 -0
  31. package/src/services/diff.js +153 -0
  32. package/src/services/doctor.js +126 -0
  33. package/src/services/logger.js +190 -0
  34. package/src/services/preferences.js +249 -0
  35. package/src/services/processes.js +345 -0
  36. package/src/services/provider-settings.js +226 -0
  37. package/src/services/sessions.js +279 -0
  38. package/src/services/undo.js +133 -0
  39. package/src/services/ushell.js +146 -0
  40. package/src/services/websearch/manager.js +616 -0
  41. package/src/services/websearch/mcp.js +233 -0
  42. package/src/services/websearch/normalize.js +284 -0
  43. package/src/services/websearch/providers.js +178 -0
  44. package/src/services/websearch/serper.js +263 -0
  45. package/src/services/workspace.js +589 -0
  46. package/src/tools/filesystem.js +304 -0
  47. package/src/tools/git.js +114 -0
  48. package/src/tools/index.js +48 -0
  49. package/src/tools/project.js +160 -0
  50. package/src/tools/registry.js +118 -0
  51. package/src/tools/search.js +210 -0
  52. package/src/tools/shell.js +279 -0
  53. package/src/tools/url.js +542 -0
  54. package/src/tools/web.js +67 -0
  55. package/src/utils/errors.js +160 -0
  56. package/src/utils/network.js +129 -0
  57. package/src/utils/redaction.js +73 -0
  58. package/src/utils/validation.js +370 -0
  59. package/src/utils/version.js +24 -0
@@ -0,0 +1,226 @@
1
+ import { getSetting, updateSettings } from '../config/settings.js';
2
+ import {
3
+ BUILTIN_PROVIDERS,
4
+ MAX_EXTRA_PROVIDERS,
5
+ MAX_MODELS_PER_PROVIDER,
6
+ normalizeModelId,
7
+ normalizeProviderId,
8
+ } from '../providers/catalog.js';
9
+
10
+ /**
11
+ * Provider and model management for config.json.
12
+ *
13
+ * `/provider add|del` and `/addmodel|/delmodel` edit the same `extraProviders`
14
+ * list the file already uses, so a provider added at runtime is indistinguishable
15
+ * from one written by hand and survives restarts. Built-in providers are
16
+ * read-only: only their added model ids (in .ryuu/preferences.json) are editable.
17
+ */
18
+
19
+ const BUILTIN_IDS = new Set(BUILTIN_PROVIDERS.map((provider) => provider.id));
20
+
21
+ export function isBuiltinProviderId(id) {
22
+ return BUILTIN_IDS.has(String(id ?? '').trim().toLowerCase());
23
+ }
24
+
25
+ /** The stored extraProviders array, never the module-level default. */
26
+ export function readExtraProviders(settings) {
27
+ const value = getSetting(settings, 'extraProviders');
28
+ return Array.isArray(value) ? value : [];
29
+ }
30
+
31
+ function modelIdsOf(entry) {
32
+ if (!Array.isArray(entry?.models)) return [];
33
+ return entry.models
34
+ .map((model) => normalizeModelId(typeof model === 'string' ? model : model?.id))
35
+ .filter(Boolean);
36
+ }
37
+
38
+ function normalizeBaseUrl(value) {
39
+ const text = String(value ?? '').trim();
40
+ if (!text) return null;
41
+ try {
42
+ const parsed = new URL(text);
43
+ if (!['http:', 'https:'].includes(parsed.protocol)) return null;
44
+ if (parsed.username || parsed.password) return null;
45
+ return parsed.toString().replace(/\/$/, '');
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Add a user provider. The id comes from the name, so `/provider add river …`
53
+ * creates `river` and stores its key under `providers.river.apiKey`.
54
+ * Returns { ok, reason } with reason 'builtin' | 'exists' | 'invalid' |
55
+ * 'url' | 'limit' | 'write'.
56
+ */
57
+ export async function addExtraProvider({ name, baseURL, apiKey = '', label = null }) {
58
+ const id = normalizeProviderId(name);
59
+ if (!id) return { ok: false, reason: 'invalid' };
60
+ if (BUILTIN_IDS.has(id)) return { ok: false, reason: 'builtin' };
61
+ const url = normalizeBaseUrl(baseURL);
62
+ if (!url) return { ok: false, reason: 'url' };
63
+ let reason = null;
64
+ let updated = false;
65
+ try {
66
+ await updateSettings((draft) => {
67
+ const entries = readExtraProviders(draft).filter((entry) => entry && typeof entry === 'object');
68
+ const existing = entries.find((entry) => normalizeProviderId(entry.id) === id);
69
+ if (existing) {
70
+ // Re-adding with a key is the documented way to update it.
71
+ if (!apiKey) {
72
+ reason = 'exists';
73
+ return draft;
74
+ }
75
+ draft.providers = draft.providers && typeof draft.providers === 'object' ? draft.providers : {};
76
+ const stored = draft.providers[id] && typeof draft.providers[id] === 'object' ? draft.providers[id] : {};
77
+ draft.providers[id] = { ...stored, apiKey: String(apiKey).trim() };
78
+ updated = true;
79
+ return draft;
80
+ }
81
+ if (entries.length >= MAX_EXTRA_PROVIDERS) {
82
+ reason = 'limit';
83
+ return draft;
84
+ }
85
+ entries.push({
86
+ id,
87
+ name: label || name,
88
+ baseURL: url,
89
+ models: [],
90
+ capabilities: { streaming: true, tools: true, reasoning: false, multimodal: false },
91
+ });
92
+ draft.extraProviders = entries;
93
+ if (apiKey) {
94
+ draft.providers = draft.providers && typeof draft.providers === 'object' ? draft.providers : {};
95
+ const stored = draft.providers[id] && typeof draft.providers[id] === 'object' ? draft.providers[id] : {};
96
+ draft.providers[id] = { ...stored, apiKey: String(apiKey).trim() };
97
+ } else if (draft.providers && typeof draft.providers === 'object' && !draft.providers[id]) {
98
+ draft.providers[id] = { apiKey: '' };
99
+ }
100
+ return draft;
101
+ });
102
+ } catch {
103
+ return { ok: false, reason: 'write' };
104
+ }
105
+ return reason ? { ok: false, reason } : { ok: true, reason: null, id, baseURL: url, updated };
106
+ }
107
+
108
+ /**
109
+ * Remove a user provider plus its credentials and remembered model.
110
+ * Returns { ok, reason } with reason 'builtin' | 'missing' | 'active' | 'write'.
111
+ */
112
+ export async function removeExtraProvider(id, { active = false } = {}) {
113
+ const provider = normalizeProviderId(id);
114
+ if (!provider) return { ok: false, reason: 'missing' };
115
+ if (BUILTIN_IDS.has(provider)) return { ok: false, reason: 'builtin' };
116
+ if (active) return { ok: false, reason: 'active' };
117
+ let reason = null;
118
+ try {
119
+ await updateSettings((draft) => {
120
+ const entries = readExtraProviders(draft);
121
+ const index = entries.findIndex((entry) => normalizeProviderId(entry?.id) === provider);
122
+ if (index === -1) {
123
+ reason = 'missing';
124
+ return draft;
125
+ }
126
+ entries.splice(index, 1);
127
+ draft.extraProviders = entries;
128
+ if (draft.providers && typeof draft.providers === 'object') delete draft.providers[provider];
129
+ if (draft.providerModel && typeof draft.providerModel === 'object') delete draft.providerModel[provider];
130
+ return draft;
131
+ });
132
+ } catch {
133
+ return { ok: false, reason: 'write' };
134
+ }
135
+ return reason ? { ok: false, reason } : { ok: true, reason: null };
136
+ }
137
+
138
+ /**
139
+ * Append a model id to a user provider's `models`. The first model also becomes
140
+ * the provider's default. Returns { ok, reason, added, models } with reason
141
+ * 'missing' | 'builtin' | 'invalid' | 'exists' | 'limit' | 'write'.
142
+ */
143
+ export async function addExtraProviderModel(id, model, { label = null } = {}) {
144
+ const provider = normalizeProviderId(id);
145
+ const modelId = normalizeModelId(model);
146
+ if (!provider) return { ok: false, reason: 'missing' };
147
+ if (!modelId) return { ok: false, reason: 'invalid' };
148
+ if (BUILTIN_IDS.has(provider)) return { ok: false, reason: 'builtin' };
149
+ let reason = null;
150
+ let models = [];
151
+ try {
152
+ await updateSettings((draft) => {
153
+ const entries = readExtraProviders(draft);
154
+ const entry = entries.find((item) => normalizeProviderId(item?.id) === provider);
155
+ if (!entry) {
156
+ reason = 'missing';
157
+ return draft;
158
+ }
159
+ models = modelIdsOf(entry);
160
+ if (models.includes(modelId)) {
161
+ reason = 'exists';
162
+ return draft;
163
+ }
164
+ if (models.length >= MAX_MODELS_PER_PROVIDER) {
165
+ reason = 'limit';
166
+ return draft;
167
+ }
168
+ models.push(modelId);
169
+ const storedNames = Array.isArray(entry.models)
170
+ ? new Map(entry.models.map((item) => [normalizeModelId(typeof item === 'string' ? item : item?.id), typeof item === 'object' ? item?.name : null]))
171
+ : new Map();
172
+ entry.models = models.map((item) => {
173
+ const display = storedNames.get(item);
174
+ return display && display !== item ? { id: item, name: display } : item;
175
+ });
176
+ if (!normalizeModelId(entry.defaultModel)) entry.defaultModel = modelId;
177
+ entry.name = entry.name || label || provider;
178
+ return draft;
179
+ });
180
+ } catch {
181
+ return { ok: false, reason: 'write' };
182
+ }
183
+ return reason ? { ok: false, reason, models } : { ok: true, reason: null, added: modelId, models };
184
+ }
185
+
186
+ /**
187
+ * Remove a model id from a user provider and recompute its default.
188
+ * Returns { ok, reason, models, defaultModel } with reason 'missing' | 'builtin'
189
+ * | 'invalid' | 'absent' | 'write'.
190
+ */
191
+ export async function removeExtraProviderModel(id, model) {
192
+ const provider = normalizeProviderId(id);
193
+ const modelId = normalizeModelId(model);
194
+ if (!provider) return { ok: false, reason: 'missing' };
195
+ if (!modelId) return { ok: false, reason: 'invalid' };
196
+ if (BUILTIN_IDS.has(provider)) return { ok: false, reason: 'builtin' };
197
+ let reason = null;
198
+ let models = [];
199
+ let defaultModel = null;
200
+ try {
201
+ await updateSettings((draft) => {
202
+ const entries = readExtraProviders(draft);
203
+ const entry = entries.find((item) => normalizeProviderId(item?.id) === provider);
204
+ if (!entry) {
205
+ reason = 'missing';
206
+ return draft;
207
+ }
208
+ const current = modelIdsOf(entry);
209
+ if (!current.includes(modelId)) {
210
+ reason = 'absent';
211
+ models = current;
212
+ return draft;
213
+ }
214
+ models = current.filter((item) => item !== modelId);
215
+ entry.models = models;
216
+ if (normalizeModelId(entry.defaultModel) === modelId) entry.defaultModel = models[0] || null;
217
+ defaultModel = normalizeModelId(entry.defaultModel);
218
+ return draft;
219
+ });
220
+ } catch {
221
+ return { ok: false, reason: 'write' };
222
+ }
223
+ return reason
224
+ ? { ok: false, reason, models }
225
+ : { ok: true, reason: null, models, defaultModel };
226
+ }
@@ -0,0 +1,279 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { createRedactor } from '../utils/redaction.js';
6
+ import { SessionError } from '../utils/errors.js';
7
+
8
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
9
+
10
+ export function createSessionId(now = new Date()) {
11
+ const stamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
12
+ return `${stamp}-${crypto.randomBytes(3).toString('hex')}`;
13
+ }
14
+
15
+ export function createSessionRecord({ task, model, workspace, id } = {}) {
16
+ const now = new Date().toISOString();
17
+ return {
18
+ id: id || createSessionId(),
19
+ task: task || '',
20
+ model: model || '',
21
+ workspace: workspace || '',
22
+ status: 'active',
23
+ createdAt: now,
24
+ updatedAt: now,
25
+ steps: 0,
26
+ durationMs: null,
27
+ // Per-task model telemetry: how many calls, how long they took, how long
28
+ // the first token took, and how many reads the worklog made unnecessary.
29
+ modelCalls: 0,
30
+ requestMs: 0,
31
+ firstTokenMs: null,
32
+ cachedReads: 0,
33
+ metrics: {},
34
+ tokenUsage: { prompt: 0, completion: 0, total: 0 },
35
+ changedFiles: [],
36
+ commands: [],
37
+ issues: [],
38
+ plan: '',
39
+ transcript: [],
40
+ // Conversation memory handed to the next task (user/assistant turns only).
41
+ conversation: [],
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Session store under `.ryuu/sessions` (gitignored). Stored transcript entries
47
+ * are bounded previews only; full file contents and secrets are never persisted.
48
+ */
49
+ export class SessionStore {
50
+ constructor({ dir, secrets = [], logger = null }) {
51
+ this.dir = dir;
52
+ this.logger = logger;
53
+ this.redact = createRedactor(secrets);
54
+ }
55
+
56
+ #fileFor(id) {
57
+ if (!ID_PATTERN.test(id)) throw new SessionError(`Invalid session id: ${id}`, { id });
58
+ return path.join(this.dir, `${id}.json`);
59
+ }
60
+
61
+ async #ensureDir() {
62
+ await fsp.mkdir(this.dir, { recursive: true, mode: 0o700 });
63
+ }
64
+
65
+ /** Bound and redact a session before it touches disk. */
66
+ sanitize(session) {
67
+ const sanitized = {
68
+ ...session,
69
+ task: this.redact(String(session.task || '')).slice(0, 4000),
70
+ plan: this.redact(String(session.plan || '')).slice(0, 4000),
71
+ durationMs: Number.isFinite(session.durationMs) ? session.durationMs : null,
72
+ modelCalls: Number(session.modelCalls) || 0,
73
+ requestMs: Number(session.requestMs) || 0,
74
+ firstTokenMs: Number.isFinite(session.firstTokenMs) ? session.firstTokenMs : null,
75
+ cachedReads: Number(session.cachedReads) || 0,
76
+ metrics: session.metrics && typeof session.metrics === 'object' ? { ...session.metrics } : {},
77
+ worklog: this.#sanitizeWorklog(session.worklog),
78
+ changedFiles: (session.changedFiles || []).slice(0, 500),
79
+ commands: (session.commands || []).slice(0, 200).map((cmd) => ({
80
+ ...cmd,
81
+ argv: Array.isArray(cmd.argv) ? cmd.argv.map((part) => this.redact(String(part)).slice(0, 300)) : [],
82
+ output: this.redact(String(cmd.output || '')).slice(0, 2000),
83
+ })),
84
+ issues: (session.issues || []).slice(0, 100).map((issue) => this.redact(String(issue)).slice(0, 1000)),
85
+ conversation: (session.conversation || []).slice(-40).map((entry) => ({
86
+ role: entry.role === 'assistant' ? 'assistant' : 'user',
87
+ content: this.redact(String(entry.content || '')).slice(0, 4000),
88
+ })),
89
+ transcript: (session.transcript || []).slice(-200).map((entry) => ({
90
+ role: entry.role,
91
+ ...(entry.name ? { name: entry.name } : {}),
92
+ ...(entry.ok !== undefined ? { ok: entry.ok } : {}),
93
+ content: this.redact(String(entry.content || '')).slice(0, 4000),
94
+ ...(entry.durationMs !== undefined ? { durationMs: entry.durationMs } : {}),
95
+ ...(entry.at ? { at: entry.at } : {}),
96
+ })),
97
+ };
98
+ return sanitized;
99
+ }
100
+
101
+ /** Bounded, redacted worklog: enough to resume, small enough to store. */
102
+ #sanitizeWorklog(worklog) {
103
+ if (!worklog || typeof worklog !== 'object') return null;
104
+ return {
105
+ plan: this.redact(String(worklog.plan || '')).slice(0, 1500),
106
+ files: (worklog.files || []).slice(-40).map((entry) => ({
107
+ path: this.redact(String(entry?.path || '')).slice(0, 400),
108
+ read: Boolean(entry?.read),
109
+ changed: Boolean(entry?.changed),
110
+ ranges: Array.isArray(entry?.ranges) ? entry.ranges.slice(-4) : [],
111
+ mtimeMs: Number.isFinite(entry?.mtimeMs) ? entry.mtimeMs : null,
112
+ totalLines: Number.isFinite(entry?.totalLines) ? entry.totalLines : null,
113
+ lines: Number.isFinite(entry?.lines) ? entry.lines : null,
114
+ tool: entry?.tool ? String(entry.tool).slice(0, 40) : null,
115
+ })),
116
+ commands: (worklog.commands || []).slice(-12).map((command) => ({
117
+ argv: this.redact(String(command?.argv || '')).slice(0, 200),
118
+ exitCode: command?.exitCode ?? null,
119
+ durationMs: command?.durationMs ?? null,
120
+ status: command?.status ? String(command.status).slice(0, 40) : null,
121
+ })),
122
+ findings: (worklog.findings || []).slice(-12).map((finding) => this.redact(String(finding)).slice(0, 300)),
123
+ tasks: (worklog.tasks || []).slice(-6).map((entry) => ({
124
+ task: this.redact(String(entry?.task || '')).slice(0, 160),
125
+ status: String(entry?.status || '').slice(0, 40),
126
+ changedFiles: (entry?.changedFiles || []).slice(0, 8).map((file) => this.redact(String(file)).slice(0, 400)),
127
+ })),
128
+ };
129
+ }
130
+
131
+ async save(session) {
132
+ await this.#ensureDir();
133
+ const sanitized = this.sanitize({ ...session, updatedAt: new Date().toISOString() });
134
+ const file = this.#fileFor(sanitized.id);
135
+ const tmp = `${file}.${process.pid}.tmp`;
136
+ await fsp.writeFile(tmp, `${JSON.stringify(sanitized, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
137
+ await fsp.rename(tmp, file);
138
+ this.logger?.debug('session saved', { id: sanitized.id, file });
139
+ return sanitized;
140
+ }
141
+
142
+ async load(id) {
143
+ const file = this.#fileFor(id);
144
+ try {
145
+ const text = await fsp.readFile(file, 'utf8');
146
+ return JSON.parse(text);
147
+ } catch (error) {
148
+ if (error.code === 'ENOENT') throw new SessionError(`Session not found: ${id}`, { id });
149
+ if (error instanceof SyntaxError) throw new SessionError(`Session file is corrupt: ${id}`, { id });
150
+ throw new SessionError(`Cannot load session ${id}: ${error.message}`, { id });
151
+ }
152
+ }
153
+
154
+ async list({ limit = 50 } = {}) {
155
+ if (!fs.existsSync(this.dir)) return [];
156
+ const entries = await fsp.readdir(this.dir);
157
+ const sessions = [];
158
+ for (const entry of entries) {
159
+ if (!entry.endsWith('.json')) continue;
160
+ const id = entry.slice(0, -5);
161
+ if (!ID_PATTERN.test(id)) continue;
162
+ try {
163
+ const data = JSON.parse(await fsp.readFile(path.join(this.dir, entry), 'utf8'));
164
+ sessions.push({
165
+ id: data.id || id,
166
+ task: (data.task || '').slice(0, 120),
167
+ status: data.status,
168
+ model: data.model,
169
+ createdAt: data.createdAt,
170
+ updatedAt: data.updatedAt,
171
+ steps: data.steps || 0,
172
+ durationMs: Number.isFinite(data.durationMs) ? data.durationMs : null,
173
+ tokenUsage: data.tokenUsage || null,
174
+ changedFiles: (data.changedFiles || []).length,
175
+ });
176
+ } catch {
177
+ sessions.push({ id, task: '(unreadable session file)', status: 'corrupt', createdAt: null, updatedAt: null });
178
+ }
179
+ }
180
+ sessions.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
181
+ return sessions.slice(0, limit);
182
+ }
183
+
184
+ async delete(id) {
185
+ const file = this.#fileFor(id);
186
+ try {
187
+ await fsp.unlink(file);
188
+ return true;
189
+ } catch (error) {
190
+ if (error.code === 'ENOENT') return false;
191
+ throw new SessionError(`Cannot delete session ${id}: ${error.message}`, { id });
192
+ }
193
+ }
194
+
195
+ async clear() {
196
+ if (!fs.existsSync(this.dir)) return 0;
197
+ const entries = await fsp.readdir(this.dir);
198
+ let removed = 0;
199
+ for (const entry of entries) {
200
+ if (!entry.endsWith('.json')) continue;
201
+ await fsp.rm(path.join(this.dir, entry), { force: true });
202
+ removed += 1;
203
+ }
204
+ return removed;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Project summary cache. The fingerprint covers manifests and directory shape,
210
+ * so any relevant change invalidates the cached summary automatically.
211
+ */
212
+ export class ProjectSummaryCache {
213
+ constructor({ file, ttlMs = 0 }) {
214
+ this.file = file;
215
+ this.ttlMs = ttlMs;
216
+ }
217
+
218
+ fingerprint(workspace) {
219
+ const hash = crypto.createHash('sha256');
220
+ const manifests = ['package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'pyproject.toml', 'go.mod', 'Cargo.toml'];
221
+ for (const name of manifests) {
222
+ const file = path.join(workspace, name);
223
+ try {
224
+ const stat = fs.statSync(file);
225
+ hash.update(`${name}:${stat.size}:${stat.mtimeMs}`);
226
+ } catch {
227
+ hash.update(`${name}:-`);
228
+ }
229
+ }
230
+ try {
231
+ const top = fs.readdirSync(workspace, { withFileTypes: true })
232
+ .filter((entry) => !entry.name.startsWith('.'))
233
+ .map((entry) => `${entry.isDirectory() ? 'd' : 'f'}:${entry.name}`)
234
+ .sort();
235
+ hash.update(top.join('|'));
236
+ } catch {
237
+ hash.update('unreadable');
238
+ }
239
+ return hash.digest('hex').slice(0, 32);
240
+ }
241
+
242
+ read() {
243
+ try {
244
+ return JSON.parse(fs.readFileSync(this.file, 'utf8'));
245
+ } catch {
246
+ return null;
247
+ }
248
+ }
249
+
250
+ get(workspace) {
251
+ const cached = this.read();
252
+ if (!cached) return null;
253
+ const fingerprint = this.fingerprint(workspace);
254
+ if (cached.fingerprint !== fingerprint) return null;
255
+ if (this.ttlMs > 0 && Date.now() - new Date(cached.updatedAt).getTime() > this.ttlMs) return null;
256
+ return cached;
257
+ }
258
+
259
+ set(workspace, summary) {
260
+ const payload = {
261
+ fingerprint: this.fingerprint(workspace),
262
+ summary,
263
+ updatedAt: new Date().toISOString(),
264
+ };
265
+ try {
266
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
267
+ const tmp = `${this.file}.${process.pid}.tmp`;
268
+ fs.writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
269
+ fs.renameSync(tmp, this.file);
270
+ } catch {
271
+ // Cache failures are never fatal.
272
+ }
273
+ return payload;
274
+ }
275
+
276
+ invalidate() {
277
+ fs.rmSync(this.file, { force: true });
278
+ }
279
+ }
@@ -0,0 +1,133 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { FileChangedError, ToolError } from '../utils/errors.js';
5
+
6
+ /**
7
+ * Undo records exact before/after snapshots for file operations performed by
8
+ * this agent session. /undo only reverts when the file still matches the
9
+ * recorded after-state; it refuses otherwise instead of clobbering user edits.
10
+ *
11
+ * Snapshots live under .ryuu/undo (gitignored) and never include secret files,
12
+ * which the workspace tool layer blocks before an undo record can be created.
13
+ */
14
+ export class UndoManager {
15
+ constructor({ workspace, dir = null, logger = null, maxEntries = 40 } = {}) {
16
+ this.workspace = workspace;
17
+ this.dir = dir;
18
+ this.logger = logger;
19
+ this.maxEntries = maxEntries;
20
+ this.entries = [];
21
+ this.sessionId = null;
22
+ }
23
+
24
+ reset(sessionId) {
25
+ this.sessionId = sessionId;
26
+ this.entries = [];
27
+ }
28
+
29
+ record(entry) {
30
+ this.entries.push({ ...entry, at: new Date().toISOString() });
31
+ if (this.entries.length > this.maxEntries) this.entries.shift();
32
+ this.#persist();
33
+ }
34
+
35
+ list() {
36
+ return this.entries.map((entry, index) => ({
37
+ index,
38
+ kind: entry.kind,
39
+ path: entry.path,
40
+ to: entry.to,
41
+ at: entry.at,
42
+ }));
43
+ }
44
+
45
+ get size() {
46
+ return this.entries.length;
47
+ }
48
+
49
+ last() {
50
+ return this.entries.at(-1) || null;
51
+ }
52
+
53
+ async revertLast() {
54
+ const entry = this.last();
55
+ if (!entry) throw new ToolError('Nothing to undo in this session.');
56
+ await this.revert(entry);
57
+ this.entries.pop();
58
+ this.#persist();
59
+ return entry;
60
+ }
61
+
62
+ /** Revert a recorded change after checking the file has not changed again. */
63
+ async revert(entry) {
64
+ switch (entry.kind) {
65
+ case 'write': {
66
+ const current = await this.#readCurrent(entry.path);
67
+ if (current !== entry.after) {
68
+ throw new FileChangedError(`${entry.path} changed after the agent edit; refusing to overwrite. Inspect it or use git.`);
69
+ }
70
+ if (entry.before === null) {
71
+ await this.workspace.remove(entry.path);
72
+ } else {
73
+ await this.workspace.writeText(entry.path, entry.before);
74
+ }
75
+ break;
76
+ }
77
+ case 'delete': {
78
+ if (await this.workspace.exists(entry.path)) {
79
+ throw new FileChangedError(`${entry.path} exists again; refusing to overwrite it with the deleted snapshot.`);
80
+ }
81
+ await this.workspace.writeText(entry.path, entry.before);
82
+ break;
83
+ }
84
+ case 'move': {
85
+ if (!(await this.workspace.exists(entry.to))) {
86
+ throw new FileChangedError(`Moved file is missing: ${entry.to}. Cannot move it back.`);
87
+ }
88
+ await this.workspace.move(entry.to, entry.path, { overwrite: false });
89
+ break;
90
+ }
91
+ default:
92
+ throw new ToolError(`Unknown undo entry kind: ${entry.kind}`);
93
+ }
94
+ this.logger?.info('undo applied', { kind: entry.kind, path: entry.path });
95
+ return entry;
96
+ }
97
+
98
+ async #readCurrent(relPath) {
99
+ try {
100
+ const file = await this.workspace.readText(relPath);
101
+ return file.text;
102
+ } catch (error) {
103
+ if (error.code === 'NOT_FOUND') return null;
104
+ throw error;
105
+ }
106
+ }
107
+
108
+ #persist() {
109
+ if (!this.dir || !this.sessionId) return;
110
+ try {
111
+ fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
112
+ const tmp = path.join(this.dir, `${this.sessionId}.tmp`);
113
+ fs.writeFileSync(tmp, `${JSON.stringify(this.entries, null, 2)}\n`, { mode: 0o600 });
114
+ fs.renameSync(tmp, path.join(this.dir, `${this.sessionId}.json`));
115
+ } catch (error) {
116
+ this.logger?.warn('failed to persist undo stack', { error: error.message });
117
+ }
118
+ }
119
+
120
+ async load(sessionId) {
121
+ if (!this.dir) return 0;
122
+ this.sessionId = sessionId;
123
+ try {
124
+ const raw = await fsp.readFile(path.join(this.dir, `${sessionId}.json`), 'utf8');
125
+ const parsed = JSON.parse(raw);
126
+ this.entries = Array.isArray(parsed) ? parsed : [];
127
+ return this.entries.length;
128
+ } catch {
129
+ this.entries = [];
130
+ return 0;
131
+ }
132
+ }
133
+ }