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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +592 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1269 -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 +471 -0
  11. package/src/cli/commands.js +1155 -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 +137 -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 +461 -0
  25. package/src/index.js +449 -0
  26. package/src/providers/catalog.js +407 -0
  27. package/src/providers/ids.js +24 -0
  28. package/src/providers/index.js +103 -0
  29. package/src/providers/nvidia.js +671 -0
  30. package/src/providers/registry.js +104 -0
  31. package/src/providers/river.js +198 -0
  32. package/src/providers/tokenharbor.js +38 -0
  33. package/src/providers/vendor-extras.js +82 -0
  34. package/src/services/clipboard.js +38 -0
  35. package/src/services/diff.js +153 -0
  36. package/src/services/doctor.js +126 -0
  37. package/src/services/logger.js +190 -0
  38. package/src/services/preferences.js +249 -0
  39. package/src/services/processes.js +345 -0
  40. package/src/services/provider-settings.js +226 -0
  41. package/src/services/sessions.js +279 -0
  42. package/src/services/undo.js +133 -0
  43. package/src/services/ushell.js +146 -0
  44. package/src/services/websearch/manager.js +616 -0
  45. package/src/services/websearch/mcp.js +233 -0
  46. package/src/services/websearch/normalize.js +284 -0
  47. package/src/services/websearch/providers.js +178 -0
  48. package/src/services/websearch/serper.js +263 -0
  49. package/src/services/workspace.js +589 -0
  50. package/src/tools/filesystem.js +304 -0
  51. package/src/tools/git.js +114 -0
  52. package/src/tools/index.js +48 -0
  53. package/src/tools/project.js +160 -0
  54. package/src/tools/registry.js +118 -0
  55. package/src/tools/search.js +210 -0
  56. package/src/tools/shell.js +279 -0
  57. package/src/tools/url.js +542 -0
  58. package/src/tools/web.js +67 -0
  59. package/src/utils/errors.js +160 -0
  60. package/src/utils/network.js +129 -0
  61. package/src/utils/redaction.js +73 -0
  62. package/src/utils/validation.js +370 -0
  63. package/src/utils/version.js +24 -0
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Session worklog — the facts the agent has already established.
3
+ *
4
+ * The conversation is the model's short-term memory, but it is bounded,
5
+ * compacted, and dropped between tasks. That forced the agent to re-read the
6
+ * same files and re-derive the same conclusions on every follow-up task, which
7
+ * was the single largest source of wasted time and tokens.
8
+ *
9
+ * This module keeps a small, deterministic record of *verified* work — which
10
+ * files were read (and which line ranges), which files changed, which commands
11
+ * ran with which exit codes, and what the agent planned — so the next request
12
+ * can be told "you already know this" instead of asking it to start over.
13
+ *
14
+ * Nothing here is model-generated: every entry comes from a real tool result.
15
+ */
16
+
17
+ const MAX_FILES = 40;
18
+ const MAX_COMMANDS = 12;
19
+ const MAX_FINDINGS = 12;
20
+ const MAX_TASKS = 6;
21
+ const MAX_BRIEF_CHARS = 6000;
22
+
23
+ const clean = (value, max) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
24
+
25
+ const formatDuration = (ms) => {
26
+ const value = Number(ms || 0);
27
+ if (value < 1000) return `${value}ms`;
28
+ if (value < 60000) return `${(value / 1000).toFixed(1)}s`;
29
+ return `${Math.floor(value / 60000)}m${Math.round((value % 60000) / 1000)}s`;
30
+ };
31
+
32
+ export class Worklog {
33
+ constructor({ maxChars = MAX_BRIEF_CHARS } = {}) {
34
+ this.maxChars = maxChars;
35
+ this.files = new Map();
36
+ this.commands = [];
37
+ this.findings = [];
38
+ this.tasks = [];
39
+ this.plan = '';
40
+ }
41
+
42
+ reset() {
43
+ this.files.clear();
44
+ this.commands = [];
45
+ this.findings = [];
46
+ this.tasks = [];
47
+ this.plan = '';
48
+ }
49
+
50
+ get size() {
51
+ return this.files.size + this.commands.length + this.findings.length;
52
+ }
53
+
54
+ /** A file entry, or null when the path was never seen in this session. */
55
+ file(relPath) {
56
+ if (!relPath) return null;
57
+ return this.files.get(String(relPath).split('\\').join('/')) || null;
58
+ }
59
+
60
+ /**
61
+ * True when re-reading this exact path/range cannot produce new information:
62
+ * the file was read before, the same range was requested, and the file's
63
+ * mtime has not changed since.
64
+ */
65
+ canSkipRead(relPath, { startLine = 1, endLine = null, mtimeMs = null } = {}) {
66
+ const entry = this.file(relPath);
67
+ if (!entry || !entry.read) return false;
68
+ if (!entry.ranges.some(([start, end]) => start === startLine && (endLine === null || end === endLine))) {
69
+ return false;
70
+ }
71
+ // Unknown freshness is never treated as fresh: re-reading a file that may
72
+ // have changed is cheaper than acting on a stale snapshot.
73
+ if (mtimeMs === null || mtimeMs === undefined || entry.mtimeMs === null) return false;
74
+ return Number(mtimeMs) === Number(entry.mtimeMs);
75
+ }
76
+
77
+ recordPlan(plan) {
78
+ const value = String(plan || '').trim();
79
+ if (!value) return;
80
+ this.plan = value.slice(0, 1500);
81
+ }
82
+
83
+ note(finding) {
84
+ const value = clean(finding, 300);
85
+ if (!value || this.findings.includes(value)) return;
86
+ this.findings.push(value);
87
+ if (this.findings.length > MAX_FINDINGS) this.findings.shift();
88
+ }
89
+
90
+ recordRead({ path: relPath, startLine = 1, endLine = null, totalLines = null, mtimeMs = null } = {}) {
91
+ const key = this.#key(relPath);
92
+ if (!key) return;
93
+ const entry = this.files.get(key) || { path: key, read: false, changed: false, ranges: [], mtimeMs: null, totalLines: null, tool: 'read_file' };
94
+ entry.read = true;
95
+ entry.tool = 'read_file';
96
+ entry.totalLines = totalLines ?? entry.totalLines;
97
+ if (mtimeMs !== null && mtimeMs !== undefined) entry.mtimeMs = Number(mtimeMs);
98
+ const end = endLine ?? totalLines ?? startLine;
99
+ if (!entry.ranges.some(([start, stop]) => start === startLine && stop === end)) {
100
+ entry.ranges.push([startLine, end]);
101
+ if (entry.ranges.length > 8) entry.ranges.shift();
102
+ }
103
+ this.files.set(key, entry);
104
+ this.#trimFiles();
105
+ }
106
+
107
+ recordChange({ path: relPath, tool = 'write_file', lines = null, mtimeMs = null } = {}) {
108
+ const key = this.#key(relPath);
109
+ if (!key) return;
110
+ const entry = this.files.get(key) || { path: key, read: false, changed: false, ranges: [], mtimeMs: null, totalLines: null, tool };
111
+ entry.changed = true;
112
+ entry.tool = tool;
113
+ if (lines !== null && lines !== undefined) entry.lines = Number(lines);
114
+ if (mtimeMs !== null && mtimeMs !== undefined) entry.mtimeMs = Number(mtimeMs);
115
+ // After an edit the earlier read is stale: a verification read is allowed.
116
+ entry.read = false;
117
+ entry.ranges = [];
118
+ this.files.set(key, entry);
119
+ this.#trimFiles();
120
+ }
121
+
122
+ recordCommand({ argv = [], exitCode = null, durationMs = null, status = null } = {}) {
123
+ const text = Array.isArray(argv) ? argv.join(' ') : String(argv || '');
124
+ if (!text) return;
125
+ this.commands.push({
126
+ argv: text.slice(0, 200),
127
+ exitCode: exitCode === undefined ? null : exitCode,
128
+ durationMs: durationMs === undefined ? null : durationMs,
129
+ status: status || (exitCode === 0 ? 'ok' : 'failed'),
130
+ });
131
+ if (this.commands.length > MAX_COMMANDS) this.commands.shift();
132
+ }
133
+
134
+ recordTask({ task = '', status = '', changedFiles = [] } = {}) {
135
+ const entry = {
136
+ task: clean(task, 140),
137
+ status: clean(status, 30),
138
+ changedFiles: (changedFiles || []).slice(0, 8),
139
+ };
140
+ if (!entry.task) return;
141
+ this.tasks.push(entry);
142
+ if (this.tasks.length > MAX_TASKS) this.tasks.shift();
143
+ }
144
+
145
+ /** Feed a successful tool result into the worklog. */
146
+ recordToolResult(name, args = {}, result = {}) {
147
+ if (!result || typeof result !== 'object') return;
148
+ if (name === 'read_file' && result.path) {
149
+ this.recordRead({
150
+ path: result.path,
151
+ startLine: result.start_line,
152
+ endLine: result.end_line,
153
+ totalLines: result.total_lines,
154
+ mtimeMs: result.mtime_ms ?? args.expected_mtime_ms ?? null,
155
+ });
156
+ return;
157
+ }
158
+ if (name === 'file_info' && result.path) {
159
+ const entry = this.file(result.path);
160
+ if (entry) {
161
+ if (result.mtime_ms !== undefined) entry.mtimeMs = Number(result.mtime_ms);
162
+ if (result.lines !== undefined) entry.totalLines = Number(result.lines);
163
+ }
164
+ return;
165
+ }
166
+ if (name === 'edit_file' && result.path) {
167
+ this.recordChange({ path: result.path, tool: name, lines: result.lines, mtimeMs: result.mtime_ms });
168
+ return;
169
+ }
170
+ if ((name === 'write_file' || name === 'create_file') && (result.relPath || result.path)) {
171
+ this.recordChange({ path: result.relPath || result.path, tool: name, lines: result.lines, mtimeMs: result.mtime_ms });
172
+ return;
173
+ }
174
+ if (name === 'move_file') {
175
+ if (result.from) this.recordChange({ path: result.from, tool: 'delete_file' });
176
+ if (result.to) this.recordChange({ path: result.to, tool: 'create_file' });
177
+ return;
178
+ }
179
+ if (name === 'delete_file' && result.relPath) {
180
+ this.recordChange({ path: result.relPath, tool: 'delete_file' });
181
+ return;
182
+ }
183
+ if (name === 'run_command') {
184
+ this.recordCommand({ argv: result.argv || args.argv, exitCode: result.exit_code, durationMs: result.duration_ms, status: result.status });
185
+ return;
186
+ }
187
+ if (name === 'search_files') {
188
+ const query = args.query || args.pattern || args.text || '';
189
+ const count = result.count ?? result.matches?.length ?? 0;
190
+ if (query) this.note(`search_files "${clean(query, 80)}" -> ${count} match(es)`);
191
+ return;
192
+ }
193
+ if (name === 'project_summary') {
194
+ this.note(`project_summary: ${result.name || 'unnamed'} · ${result.totalFilesScanned ?? 0} files scanned`);
195
+ return;
196
+ }
197
+ if (name === 'read_url') {
198
+ const url = clean(result.final_url || result.url || args.url || '', 160);
199
+ if (url) {
200
+ const via = result.extraction === 'mcp' && result.provider ? ` · via ${result.provider} MCP` : '';
201
+ this.note(`read_url ${url} -> HTTP ${result.status ?? '?'} · ${(result.text || '').length} chars${via}`);
202
+ }
203
+ return;
204
+ }
205
+ if (name === 'web_search') {
206
+ const query = clean(args.query || result.query || '', 80);
207
+ if (!query) return;
208
+ const count = result.result_count ?? result.results?.length ?? 0;
209
+ const provider = result.provider ? ` · provider: ${result.provider}` : '';
210
+ const fallback = result.fallback_used ? ' · fallback' : '';
211
+ const top = result.results?.[0]?.url ? ` · top: ${clean(result.results[0].url, 120)}` : '';
212
+ this.note(`web_search "${query}" -> ${count} result(s)${provider}${fallback}${top}`);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * The brief handed to the model once per task. It is deliberately short and
218
+ * factual: paths, line ranges, exit codes — the things that stop a re-read.
219
+ */
220
+ brief({ maxChars = this.maxChars, budget = null } = {}) {
221
+ const lines = ['SESSION WORKLOG — verified facts from earlier work in this session.'];
222
+ lines.push('Treat these as ground truth. Do not re-analyse, re-list, or re-read them unless the file changed on disk.');
223
+
224
+ const changed = [...this.files.values()].filter((entry) => entry.changed);
225
+ const read = [...this.files.values()].filter((entry) => entry.read && !entry.changed);
226
+ if (changed.length > 0) {
227
+ lines.push('', 'FILES YOU ALREADY CHANGED (verify narrowly or run a test — do not read them back in full):');
228
+ for (const entry of changed.slice(-16)) {
229
+ lines.push(`- ${entry.path} · ${entry.tool}${entry.lines ? ` · ${entry.lines} lines` : ''}`);
230
+ }
231
+ }
232
+ if (read.length > 0) {
233
+ lines.push('', 'FILES ALREADY READ (content was sent earlier — reuse it instead of reading again):');
234
+ for (const entry of read.slice(-16)) {
235
+ const ranges = entry.ranges.slice(-3).map(([start, end]) => `${start}-${end}`).join(', ') || 'all';
236
+ lines.push(`- ${entry.path} · lines ${ranges}${entry.totalLines ? ` of ${entry.totalLines}` : ''}`);
237
+ }
238
+ }
239
+ if (this.commands.length > 0) {
240
+ lines.push('', 'COMMANDS ALREADY RUN (re-running the same argv cannot produce new information):');
241
+ for (const command of this.commands.slice(-8)) {
242
+ const exit = command.exitCode === null || command.exitCode === undefined ? '?' : command.exitCode;
243
+ lines.push(`- ${command.argv} -> exit ${exit}${command.durationMs ? ` (${formatDuration(command.durationMs)})` : ''}`);
244
+ }
245
+ }
246
+ if (this.findings.length > 0) {
247
+ lines.push('', 'FINDINGS ALREADY ESTABLISHED:');
248
+ for (const finding of this.findings.slice(-8)) lines.push(`- ${finding}`);
249
+ }
250
+ if (this.plan) {
251
+ lines.push('', 'PLAN IN PROGRESS (continue it, do not start a new one):', ...this.plan.split('\n').slice(0, 8).map((line) => `- ${line.trim()}`));
252
+ }
253
+ if (this.tasks.length > 0) {
254
+ lines.push('', 'EARLIER TASKS IN THIS SESSION:');
255
+ for (const entry of this.tasks.slice(-4)) {
256
+ lines.push(`- "${entry.task}" -> ${entry.status}${entry.changedFiles.length ? ` · changed: ${entry.changedFiles.join(', ')}` : ''}`);
257
+ }
258
+ }
259
+ if (budget) lines.push('', `BUDGET REMAINING: ${budget}`);
260
+ return lines.join('\n').slice(0, maxChars);
261
+ }
262
+
263
+ /** Serializable, bounded snapshot for the session file. */
264
+ toJSON() {
265
+ return {
266
+ files: [...this.files.values()].slice(-MAX_FILES).map((entry) => ({
267
+ path: entry.path,
268
+ read: Boolean(entry.read),
269
+ changed: Boolean(entry.changed),
270
+ ranges: entry.ranges.slice(-4),
271
+ mtimeMs: entry.mtimeMs ?? null,
272
+ totalLines: entry.totalLines ?? null,
273
+ lines: entry.lines ?? null,
274
+ tool: entry.tool || null,
275
+ })),
276
+ commands: this.commands.slice(-MAX_COMMANDS),
277
+ findings: this.findings.slice(-MAX_FINDINGS),
278
+ tasks: this.tasks.slice(-MAX_TASKS),
279
+ plan: this.plan,
280
+ };
281
+ }
282
+
283
+ static fromJSON(data) {
284
+ const log = new Worklog();
285
+ if (!data || typeof data !== 'object') return log;
286
+ for (const entry of data.files || []) {
287
+ if (!entry?.path) continue;
288
+ log.files.set(entry.path, {
289
+ path: entry.path,
290
+ read: Boolean(entry.read),
291
+ changed: Boolean(entry.changed),
292
+ ranges: Array.isArray(entry.ranges) ? entry.ranges.filter(Array.isArray).slice(-4) : [],
293
+ mtimeMs: entry.mtimeMs ?? null,
294
+ totalLines: entry.totalLines ?? null,
295
+ lines: entry.lines ?? null,
296
+ tool: entry.tool || null,
297
+ });
298
+ }
299
+ log.commands = Array.isArray(data.commands) ? data.commands.slice(-MAX_COMMANDS) : [];
300
+ log.findings = Array.isArray(data.findings) ? data.findings.slice(-MAX_FINDINGS) : [];
301
+ log.tasks = Array.isArray(data.tasks) ? data.tasks.slice(-MAX_TASKS) : [];
302
+ log.plan = String(data.plan || '');
303
+ return log;
304
+ }
305
+
306
+ #key(relPath) {
307
+ const value = String(relPath || '').trim().split('\\').join('/').replace(/^\.\//, '');
308
+ if (!value) return '';
309
+ return value.slice(0, 400);
310
+ }
311
+
312
+ #trimFiles() {
313
+ while (this.files.size > MAX_FILES) {
314
+ const oldest = this.files.keys().next().value;
315
+ this.files.delete(oldest);
316
+ }
317
+ }
318
+ }