aiden-runtime 4.9.2 → 4.9.4
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 +47 -1
- package/dist/cli/v4/chatSession.js +17 -0
- package/dist/cli/v4/commands/greeter.js +86 -0
- package/dist/cli/v4/commands/help.js +2 -0
- package/dist/cli/v4/commands/index.js +4 -0
- package/dist/cli/v4/greeter/history.js +134 -0
- package/dist/cli/v4/greeter/index.js +147 -0
- package/dist/cli/v4/greeter/scan.js +140 -0
- package/dist/cli/v4/greeter/selectOffer.js +118 -0
- package/dist/cli/v4/greeter/templates.js +51 -0
- package/dist/cli/v4/greeter/types.js +23 -0
- package/dist/core/v4/aidenAgent.js +47 -5
- package/dist/core/v4/toolCallInvariant.js +150 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -105,6 +105,13 @@ Windows · Linux · WSL · macOS (API Mode)
|
|
|
105
105
|
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
https://github.com/user-attachments/assets/1081e5c5-f1ec-4980-b710-1640981ec58b
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|
|
108
115
|
> A semi-autonomous AI agent that runs on your machine. Touches your files, browser, and shell. Remembers what matters. Built solo. Open source. Still rough in spots.
|
|
109
116
|
|
|
110
117
|
<br>
|
|
@@ -130,7 +137,13 @@ Drop a file in `~/Documents/inbox/anything.txt` and Aiden acts on it. The agent
|
|
|
130
137
|
|
|
131
138
|
<br>
|
|
132
139
|
|
|
133
|
-
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
https://github.com/user-attachments/assets/7a66bc19-8b17-4b01-be85-3aa5945a1b3b
|
|
145
|
+
|
|
146
|
+
|
|
134
147
|
|
|
135
148
|
<br>
|
|
136
149
|
|
|
@@ -219,6 +232,13 @@ Full v4.5 internals: [`docs/v4.5/`](docs/v4.5/) (overview, triggers, architectur
|
|
|
219
232
|
| **MCP** | Model Context Protocol bridge — stdio + HTTP transports, schema discovery, tool dispatch. |
|
|
220
233
|
| **Security moat** | Tiered approval engine (`safe` / `caution` / `dangerous`), dangerous-command pattern classifier, honesty enforcement (post-loop scan rewrites false claims), memory guard, planner-guard tool narrowing, SSRF-safe URL fetcher, secret/PII pre-write scanner, skill-teacher (auto-create skills from successful flows). |
|
|
221
234
|
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
|
|
222
242
|
<br>
|
|
223
243
|
|
|
224
244
|
## Architecture
|
|
@@ -229,6 +249,13 @@ Detailed diagrams + module map in [`docs/v4.5/architecture.md`](docs/v4.5/archit
|
|
|
229
249
|
|
|
230
250
|
<br>
|
|
231
251
|
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
https://github.com/user-attachments/assets/323c9aa7-959a-425a-a5b3-4bae2b1a14bc
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
|
|
232
259
|
## Install + first run
|
|
233
260
|
|
|
234
261
|
### Linux / WSL / macOS (one-line)
|
|
@@ -285,6 +312,10 @@ Remove-Item -Recurse -Force $env:LOCALAPPDATA\aiden
|
|
|
285
312
|
<img width="938" height="1049" alt="preview (3)" src="https://github.com/user-attachments/assets/4e32ae38-74ad-433d-b986-0a15bc2dffec" />
|
|
286
313
|
|
|
287
314
|
|
|
315
|
+
|
|
316
|
+
https://github.com/user-attachments/assets/398e1d48-cc5a-4fb5-a195-05dbef824198
|
|
317
|
+
|
|
318
|
+
|
|
288
319
|
## Recommended terminal setup
|
|
289
320
|
|
|
290
321
|
For best visual rendering, Aiden looks crispest with:
|
|
@@ -488,6 +519,21 @@ Common issues live in [`docs/v4.5/troubleshooting.md`](docs/v4.5/troubleshooting
|
|
|
488
519
|
- **`/help` doesn't list a command** — that command likely needs an active session field; run from a real REPL
|
|
489
520
|
- **`npm install` permission errors on Windows** — install into a real folder (not a drive root like `S:\`)
|
|
490
521
|
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
https://github.com/user-attachments/assets/3acc997f-1d71-45d1-9955-11b67abd0c50
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
https://github.com/user-attachments/assets/9e734168-cf76-4cc0-975a-379e5402ee90
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
https://github.com/user-attachments/assets/5e7011a5-630d-43bd-8ed3-67084c7645db
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
|
|
491
537
|
<br>
|
|
492
538
|
|
|
493
539
|
## The book
|
|
@@ -83,6 +83,7 @@ const uiBuild_1 = require("./uiBuild");
|
|
|
83
83
|
const sessionSummaryGate_1 = require("./sessionSummaryGate");
|
|
84
84
|
const aidenPrompt_1 = __importDefault(require("./aidenPrompt"));
|
|
85
85
|
const confirmPrompt_1 = require("./confirmPrompt");
|
|
86
|
+
const greeter_1 = require("./greeter");
|
|
86
87
|
const historyStore_1 = require("./historyStore");
|
|
87
88
|
const modelMetadata_1 = require("../../core/v4/modelMetadata");
|
|
88
89
|
// v4.1.3-prebump: classify provider errors so the catch path can show
|
|
@@ -1681,6 +1682,22 @@ class ChatSession {
|
|
|
1681
1682
|
}
|
|
1682
1683
|
}
|
|
1683
1684
|
catch { /* never let a missing marker crash boot */ }
|
|
1685
|
+
// v4.9.3 Slice 1b — boot greeter. Silent on first-ever launch (lets
|
|
1686
|
+
// renderFirstRunHint above own boot #1), silent when /greeter off,
|
|
1687
|
+
// silent when no offer wins. Lazy-required so test-harness sessions
|
|
1688
|
+
// without `paths` wired skip the fs cost. Internal errors are
|
|
1689
|
+
// already swallowed inside renderGreeter; outer try/catch is the
|
|
1690
|
+
// belt-and-braces guarantee against a boot-crash regression.
|
|
1691
|
+
try {
|
|
1692
|
+
if (this.opts.paths) {
|
|
1693
|
+
await (0, greeter_1.renderGreeter)({
|
|
1694
|
+
paths: this.opts.paths,
|
|
1695
|
+
version: version_1.VERSION,
|
|
1696
|
+
display: this.opts.display,
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
catch { /* never let the greeter crash boot */ }
|
|
1684
1701
|
// v4.9.0 pre-ship UI: hint moved BEFORE the closing rule so the
|
|
1685
1702
|
// rule sits adjacent to the active prompt (it becomes the visual
|
|
1686
1703
|
// top of the prompt zone). New order: blank · hint · blank · rule.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* cli/v4/commands/greeter.ts — v4.9.3 SLICE 1a.
|
|
10
|
+
*
|
|
11
|
+
* `/greeter on | off | status` — REPL controls for the boot greeter.
|
|
12
|
+
* - on → set disabled: false; init the file if missing
|
|
13
|
+
* - off → confirm-gated (uses v4.9.2 Slice 3 ctx.confirm), set
|
|
14
|
+
* disabled: true
|
|
15
|
+
* - status → print current state + last greeting + offer summary
|
|
16
|
+
*
|
|
17
|
+
* Slice 1a: the slash command operates on the history file even
|
|
18
|
+
* though the greeter doesn't fire on boot yet (Slice 1b wires that).
|
|
19
|
+
* `/greeter off` BEFORE Slice 1b still durably disables, so when
|
|
20
|
+
* Slice 1b lands the user's choice is already honored.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.greeter = void 0;
|
|
24
|
+
const history_1 = require("../greeter/history");
|
|
25
|
+
exports.greeter = {
|
|
26
|
+
name: 'greeter',
|
|
27
|
+
description: 'Manage the boot greeter. Actions: on, off, status.',
|
|
28
|
+
category: 'system',
|
|
29
|
+
icon: '👋',
|
|
30
|
+
handler: async (ctx) => {
|
|
31
|
+
if (!ctx.paths) {
|
|
32
|
+
ctx.display.printError('Cannot read greeter state — paths not wired in this session.');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const sub = (ctx.args[0] ?? 'status').toLowerCase();
|
|
36
|
+
if (sub === 'on') {
|
|
37
|
+
const h = (await (0, history_1.readHistory)(ctx.paths)) ?? initial();
|
|
38
|
+
h.disabled = false;
|
|
39
|
+
await (0, history_1.writeHistory)(ctx.paths, h);
|
|
40
|
+
ctx.display.success('Greeter on. Next boot will check for noticeable changes.');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (sub === 'off') {
|
|
44
|
+
if (!ctx.confirm) {
|
|
45
|
+
ctx.display.printError('Cannot confirm in this context.');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const proceed = await ctx.confirm('Turn the boot greeter off? You can re-enable with /greeter on.');
|
|
49
|
+
if (!proceed)
|
|
50
|
+
return; // confirm() already printed the rejection reason
|
|
51
|
+
const h = (await (0, history_1.readHistory)(ctx.paths)) ?? initial();
|
|
52
|
+
h.disabled = true;
|
|
53
|
+
await (0, history_1.writeHistory)(ctx.paths, h);
|
|
54
|
+
ctx.display.success('Greeter off. No greeting on boot until /greeter on.');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (sub === 'status') {
|
|
58
|
+
const h = await (0, history_1.readHistory)(ctx.paths);
|
|
59
|
+
if (!h) {
|
|
60
|
+
ctx.display.dim('Greeter has not been initialized yet (no boots since v4.9.3).');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const state = h.disabled ? 'off' : 'on';
|
|
64
|
+
const accepted = h.offers.filter((o) => o.response === 'accepted').length;
|
|
65
|
+
const ignored = h.offers.filter((o) => o.response === 'ignored').length;
|
|
66
|
+
const pending = h.offers.filter((o) => !o.response).length;
|
|
67
|
+
ctx.display.write('\n Greeter status:\n');
|
|
68
|
+
ctx.display.write(` state: ${state}\n`);
|
|
69
|
+
ctx.display.write(` first launch: ${h.firstLaunchAt}\n`);
|
|
70
|
+
ctx.display.write(` last greeting: ${h.lastGreetingAt}\n`);
|
|
71
|
+
ctx.display.write(` offers: ${h.offers.length} (${accepted} accepted · ${ignored} ignored · ${pending} pending)\n\n`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
ctx.display.printError(`Unknown greeter action '${sub}'.`, 'Try: /greeter on | off | status');
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
function initial() {
|
|
78
|
+
const now = new Date().toISOString();
|
|
79
|
+
return {
|
|
80
|
+
v: 1,
|
|
81
|
+
firstLaunchAt: now,
|
|
82
|
+
lastGreetingAt: now,
|
|
83
|
+
offers: [],
|
|
84
|
+
disabled: false,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -79,6 +79,8 @@ exports.SUBSECTION_MAP = {
|
|
|
79
79
|
// v4.9.1 amendment — REPL surfaces for memory + hooks (daemon already mapped).
|
|
80
80
|
memory: 'System',
|
|
81
81
|
hooks: 'System',
|
|
82
|
+
// v4.9.3 Slice 1b — boot greeter management.
|
|
83
|
+
greeter: 'System',
|
|
82
84
|
// ── Authentication ──
|
|
83
85
|
auth: 'Authentication',
|
|
84
86
|
// ── Help ──
|
|
@@ -111,6 +111,8 @@ const memorySlash_1 = require("./memorySlash");
|
|
|
111
111
|
Object.defineProperty(exports, "memory", { enumerable: true, get: function () { return memorySlash_1.memory; } });
|
|
112
112
|
const hooksSlash_1 = require("./hooksSlash");
|
|
113
113
|
Object.defineProperty(exports, "hooks", { enumerable: true, get: function () { return hooksSlash_1.hooks; } });
|
|
114
|
+
// v4.9.3 Slice 1b — boot greeter management.
|
|
115
|
+
const greeter_1 = require("./greeter");
|
|
114
116
|
/** All built-in system commands, in canonical order. */
|
|
115
117
|
exports.allCommands = [
|
|
116
118
|
help_1.help,
|
|
@@ -166,6 +168,8 @@ exports.allCommands = [
|
|
|
166
168
|
// v4.9.1 amendment — REPL slash surfaces mirroring CLI subcommands.
|
|
167
169
|
memorySlash_1.memory,
|
|
168
170
|
hooksSlash_1.hooks,
|
|
171
|
+
// v4.9.3 Slice 1b — boot greeter management.
|
|
172
|
+
greeter_1.greeter,
|
|
169
173
|
clear_1.clear,
|
|
170
174
|
quit_1.quit,
|
|
171
175
|
];
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* cli/v4/greeter/history.ts — v4.9.3 SLICE 1a.
|
|
10
|
+
*
|
|
11
|
+
* Greeter state persistence. Single JSON file at
|
|
12
|
+
* `<paths.root>/.greeter-history.json` — matches the existing
|
|
13
|
+
* `.first-run-shown` / `.recent-commands.json` precedent rather than
|
|
14
|
+
* carving out a `state/` subdirectory for one file.
|
|
15
|
+
*
|
|
16
|
+
* Three exported helpers:
|
|
17
|
+
* - readHistory → null when the file does not exist (first launch)
|
|
18
|
+
* - writeHistory → atomic via tmp + rename, matches the v4
|
|
19
|
+
* `upsertEnv` pattern
|
|
20
|
+
* - reconcilePending → pure function that walks the history and
|
|
21
|
+
* resolves pending offers (no `response` yet)
|
|
22
|
+
* using passive next-boot signals from the scan
|
|
23
|
+
* result. No fs IO inside; caller writes after.
|
|
24
|
+
*/
|
|
25
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.historyPath = historyPath;
|
|
30
|
+
exports.readHistory = readHistory;
|
|
31
|
+
exports.writeHistory = writeHistory;
|
|
32
|
+
exports.reconcilePending = reconcilePending;
|
|
33
|
+
const node_fs_1 = require("node:fs");
|
|
34
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
35
|
+
const types_1 = require("./types");
|
|
36
|
+
const FILE_NAME = '.greeter-history.json';
|
|
37
|
+
/** Absolute path to the greeter history file. Exported for tests. */
|
|
38
|
+
function historyPath(paths) {
|
|
39
|
+
return node_path_1.default.join(paths.root, FILE_NAME);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Read the history file. Returns null when:
|
|
43
|
+
* - the file does not exist (first launch — caller stays silent), OR
|
|
44
|
+
* - the JSON fails to parse (treat as corrupt → start fresh)
|
|
45
|
+
*
|
|
46
|
+
* Returns the parsed object on success. Schema version is checked; an
|
|
47
|
+
* unknown `v` value also returns null so a forward-incompatible file
|
|
48
|
+
* doesn't crash an older Aiden mid-boot. Real schema migrations get
|
|
49
|
+
* their own seam in a future slice.
|
|
50
|
+
*/
|
|
51
|
+
async function readHistory(paths, fsImpl = node_fs_1.promises) {
|
|
52
|
+
try {
|
|
53
|
+
const raw = await fsImpl.readFile(historyPath(paths), 'utf8');
|
|
54
|
+
const parsed = JSON.parse(raw);
|
|
55
|
+
if (parsed?.v !== 1)
|
|
56
|
+
return null;
|
|
57
|
+
return {
|
|
58
|
+
v: 1,
|
|
59
|
+
firstLaunchAt: typeof parsed.firstLaunchAt === 'string' ? parsed.firstLaunchAt : new Date().toISOString(),
|
|
60
|
+
lastGreetingAt: typeof parsed.lastGreetingAt === 'string' ? parsed.lastGreetingAt : new Date().toISOString(),
|
|
61
|
+
lastCwd: typeof parsed.lastCwd === 'string' ? parsed.lastCwd : undefined,
|
|
62
|
+
offers: Array.isArray(parsed.offers) ? parsed.offers : [],
|
|
63
|
+
disabled: parsed.disabled === true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// ENOENT or parse error — caller decides what null means.
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Atomically write the history file. tmp + rename so a process crash
|
|
73
|
+
* mid-write never leaves a half-written JSON the next boot trips on.
|
|
74
|
+
* Errors are swallowed at the boundary by callers (orchestrator) so
|
|
75
|
+
* a read-only disk doesn't crash the REPL.
|
|
76
|
+
*/
|
|
77
|
+
async function writeHistory(paths, history, fsImpl = node_fs_1.promises) {
|
|
78
|
+
await fsImpl.mkdir(paths.root, { recursive: true });
|
|
79
|
+
const dst = historyPath(paths);
|
|
80
|
+
const tmp = `${dst}.${process.pid}.tmp`;
|
|
81
|
+
await fsImpl.writeFile(tmp, JSON.stringify(history, null, 2) + '\n', 'utf8');
|
|
82
|
+
await fsImpl.rename(tmp, dst);
|
|
83
|
+
}
|
|
84
|
+
function reconcilePending(input) {
|
|
85
|
+
const { history, installedVersion, now } = input;
|
|
86
|
+
const ageDays = (offeredAt) => (now.getTime() - Date.parse(offeredAt)) / (1000 * 60 * 60 * 24);
|
|
87
|
+
const resolved = history.offers.map((o) => {
|
|
88
|
+
if (o.response)
|
|
89
|
+
return o; // already settled
|
|
90
|
+
// update-available-<targetVersion> — accepted if running >= target.
|
|
91
|
+
if (o.id.startsWith('update-available-')) {
|
|
92
|
+
const target = o.id.slice('update-available-'.length);
|
|
93
|
+
if (semverGte(installedVersion, target)) {
|
|
94
|
+
return { ...o, response: 'accepted' };
|
|
95
|
+
}
|
|
96
|
+
if (ageDays(o.offeredAt) > types_1.DECAY_DAYS_UPDATE) {
|
|
97
|
+
return { ...o, response: 'ignored' };
|
|
98
|
+
}
|
|
99
|
+
return o;
|
|
100
|
+
}
|
|
101
|
+
// Greeting-only offers (no expectedAction) — close immediately on
|
|
102
|
+
// next boot. Decay against future offers of the same id happens at
|
|
103
|
+
// selectOffer time, not here.
|
|
104
|
+
if (!o.expectedAction) {
|
|
105
|
+
return { ...o, response: 'ignored' };
|
|
106
|
+
}
|
|
107
|
+
// Other environment offers with an expectedAction — decay by env
|
|
108
|
+
// window. (Slice 1 has none in this category; v4.10 may add.)
|
|
109
|
+
if (ageDays(o.offeredAt) > types_1.DECAY_DAYS_ENVIRONMENT) {
|
|
110
|
+
return { ...o, response: 'ignored' };
|
|
111
|
+
}
|
|
112
|
+
return o;
|
|
113
|
+
});
|
|
114
|
+
return { ...history, offers: resolved };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Lightweight semver-`>=` for dot-separated numeric versions. Enough
|
|
118
|
+
* for the v4.X.Y space; does not handle pre-release tags (offer ids
|
|
119
|
+
* never carry them — they're built from `UpdateStatus.latest` which
|
|
120
|
+
* the npm registry returns as a clean release version).
|
|
121
|
+
*/
|
|
122
|
+
function semverGte(a, b) {
|
|
123
|
+
const pa = a.split('.').map((s) => Number(s) || 0);
|
|
124
|
+
const pb = b.split('.').map((s) => Number(s) || 0);
|
|
125
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
126
|
+
const va = pa[i] ?? 0;
|
|
127
|
+
const vb = pb[i] ?? 0;
|
|
128
|
+
if (va > vb)
|
|
129
|
+
return true;
|
|
130
|
+
if (va < vb)
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
return true; // equal
|
|
134
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* cli/v4/greeter/index.ts — v4.9.3 SLICE 1a.
|
|
10
|
+
*
|
|
11
|
+
* Boot-time greeter orchestrator. One shot per process.
|
|
12
|
+
*
|
|
13
|
+
* Behaviour contract (per Phase A/B):
|
|
14
|
+
* 1. SILENT on first-ever launch (history file missing). Writes a
|
|
15
|
+
* fresh v:1 history then returns. The existing renderFirstRunHint
|
|
16
|
+
* owns the first-boot moment.
|
|
17
|
+
* 2. SILENT when history.disabled === true (kill switch).
|
|
18
|
+
* 3. SILENT when no offer wins (nothing noticeable).
|
|
19
|
+
* 4. NEVER throws — internal errors are swallowed; a broken greeter
|
|
20
|
+
* must not crash the REPL.
|
|
21
|
+
* 5. Reconciles pending offers from prior boots against current
|
|
22
|
+
* scan state BEFORE selecting a new offer; the new offer (if any)
|
|
23
|
+
* is appended to history with `response` undefined (pending).
|
|
24
|
+
*/
|
|
25
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.renderGreeter = renderGreeter;
|
|
30
|
+
const node_fs_1 = require("node:fs");
|
|
31
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
32
|
+
const theme_1 = require("../../../core/v4/ui/theme");
|
|
33
|
+
const history_1 = require("./history");
|
|
34
|
+
const scan_1 = require("./scan");
|
|
35
|
+
const selectOffer_1 = require("./selectOffer");
|
|
36
|
+
/**
|
|
37
|
+
* Run the greeter exactly once. Always resolves; never throws.
|
|
38
|
+
*
|
|
39
|
+
* Returns nothing — speech (or silence) is written via display.write.
|
|
40
|
+
* Tests assert against captured display.write calls, NOT a return
|
|
41
|
+
* value (Slice 2 lesson: return-value snapshots prove nothing about
|
|
42
|
+
* what reaches the terminal).
|
|
43
|
+
*/
|
|
44
|
+
async function renderGreeter(opts) {
|
|
45
|
+
try {
|
|
46
|
+
await renderGreeterUnsafe(opts);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Greeter must never crash the REPL. Silent on any internal error.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function renderGreeterUnsafe(opts) {
|
|
53
|
+
const fsImpl = opts.fsImpl ?? node_fs_1.promises;
|
|
54
|
+
const now = opts.now ?? new Date();
|
|
55
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
56
|
+
// ── First-launch path: write fresh history, stay silent --------------
|
|
57
|
+
const existing = await (0, history_1.readHistory)(opts.paths, fsImpl);
|
|
58
|
+
if (existing === null) {
|
|
59
|
+
const fresh = {
|
|
60
|
+
v: 1,
|
|
61
|
+
firstLaunchAt: now.toISOString(),
|
|
62
|
+
lastGreetingAt: now.toISOString(),
|
|
63
|
+
lastCwd: cwd,
|
|
64
|
+
offers: [],
|
|
65
|
+
disabled: false,
|
|
66
|
+
};
|
|
67
|
+
await (0, history_1.writeHistory)(opts.paths, fresh, fsImpl);
|
|
68
|
+
return; // SILENT — renderFirstRunHint owns this moment
|
|
69
|
+
}
|
|
70
|
+
// ── Reconcile pending offers from prior boots ------------------------
|
|
71
|
+
const scanForReconcile = await (0, scan_1.runScans)({
|
|
72
|
+
paths: opts.paths,
|
|
73
|
+
cwd,
|
|
74
|
+
now,
|
|
75
|
+
version: opts.version,
|
|
76
|
+
history: existing,
|
|
77
|
+
fsImpl,
|
|
78
|
+
});
|
|
79
|
+
const reconciled = (0, history_1.reconcilePending)({
|
|
80
|
+
history: existing,
|
|
81
|
+
scan: scanForReconcile,
|
|
82
|
+
installedVersion: opts.version,
|
|
83
|
+
now,
|
|
84
|
+
});
|
|
85
|
+
// ── Pick at most one offer to render this boot ----------------------
|
|
86
|
+
const distillation = await loadLatestDistillation(opts.paths, fsImpl);
|
|
87
|
+
const offer = (0, selectOffer_1.selectOffer)({
|
|
88
|
+
scan: scanForReconcile,
|
|
89
|
+
history: reconciled,
|
|
90
|
+
now,
|
|
91
|
+
paintMuted: (s) => opts.display.paint(s, 'muted'),
|
|
92
|
+
paintAccent: (s) => theme_1.c.accent(s),
|
|
93
|
+
openItem: distillation?.openItem,
|
|
94
|
+
lastDecision: distillation?.lastDecision,
|
|
95
|
+
});
|
|
96
|
+
// ── Render (or stay silent) -----------------------------------------
|
|
97
|
+
if (offer) {
|
|
98
|
+
// 2-space indent + trailing blank to match firstRunHint layout.
|
|
99
|
+
opts.display.write(' ' + offer.speech + '\n\n');
|
|
100
|
+
}
|
|
101
|
+
// ── Persist updated history -----------------------------------------
|
|
102
|
+
const updated = {
|
|
103
|
+
...reconciled,
|
|
104
|
+
lastGreetingAt: now.toISOString(),
|
|
105
|
+
lastCwd: cwd,
|
|
106
|
+
offers: offer
|
|
107
|
+
? [...reconciled.offers, {
|
|
108
|
+
id: offer.id,
|
|
109
|
+
offeredAt: now.toISOString(),
|
|
110
|
+
expectedAction: offer.expectedAction,
|
|
111
|
+
}]
|
|
112
|
+
: reconciled.offers,
|
|
113
|
+
};
|
|
114
|
+
await (0, history_1.writeHistory)(opts.paths, updated, fsImpl);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Read the newest distillation file and extract (open_items[0],
|
|
118
|
+
* decisions[0]). Returns null when no distillations exist or any IO
|
|
119
|
+
* fails — caller (selectOffer) treats null as "no continuity signal".
|
|
120
|
+
*
|
|
121
|
+
* Slice 1 strategy: list distillationsDir, sort by filename desc (the
|
|
122
|
+
* existing distillation naming convention timestamps the filename so
|
|
123
|
+
* lexicographic sort is reverse-chronological), read the newest, parse,
|
|
124
|
+
* extract. No schema dependency on the distillation index — just the
|
|
125
|
+
* field shape.
|
|
126
|
+
*/
|
|
127
|
+
async function loadLatestDistillation(paths, fsImpl) {
|
|
128
|
+
try {
|
|
129
|
+
const dir = node_path_1.default.join(paths.root, 'distillations');
|
|
130
|
+
const entries = await fsImpl.readdir(dir);
|
|
131
|
+
if (entries.length === 0)
|
|
132
|
+
return null;
|
|
133
|
+
const newest = [...entries].sort().reverse()[0];
|
|
134
|
+
const raw = await fsImpl.readFile(node_path_1.default.join(dir, newest), 'utf8');
|
|
135
|
+
const parsed = JSON.parse(raw);
|
|
136
|
+
const openItem = Array.isArray(parsed.open_items) && typeof parsed.open_items[0] === 'string'
|
|
137
|
+
? parsed.open_items[0]
|
|
138
|
+
: null;
|
|
139
|
+
const lastDecision = Array.isArray(parsed.decisions) && typeof parsed.decisions[0] === 'string'
|
|
140
|
+
? parsed.decisions[0]
|
|
141
|
+
: null;
|
|
142
|
+
return { openItem, lastDecision };
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* cli/v4/greeter/scan.ts — v4.9.3 SLICE 1a.
|
|
10
|
+
*
|
|
11
|
+
* Four scanners feed `ScanResult`. None spawn subprocesses, none hit
|
|
12
|
+
* the network, none scan large files. Total cost target: < 20ms on
|
|
13
|
+
* a warm cache, < 50ms cold.
|
|
14
|
+
*
|
|
15
|
+
* • scanTimeOfDay — local hour from `now` (cheapest; no IO)
|
|
16
|
+
* • scanCwd — cwd vs history.lastCwd (no IO)
|
|
17
|
+
* • scanLastSessionEnd — mtime of newest distillation file
|
|
18
|
+
* • scanUpdate — reads existing `.update_check.json` cache
|
|
19
|
+
* (populated by core/v4/update/checkUpdate.ts);
|
|
20
|
+
* DOES NOT hit the npm registry itself —
|
|
21
|
+
* consumes the existing background check.
|
|
22
|
+
*
|
|
23
|
+
* Git observations are deferred to v4.10 per Phase A decision.
|
|
24
|
+
*/
|
|
25
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.runScans = runScans;
|
|
30
|
+
exports.scanCwd = scanCwd;
|
|
31
|
+
exports.scanLastSessionEnd = scanLastSessionEnd;
|
|
32
|
+
exports.scanUpdate = scanUpdate;
|
|
33
|
+
const node_fs_1 = require("node:fs");
|
|
34
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
35
|
+
/**
|
|
36
|
+
* Run all four scanners and aggregate. Pure with respect to its
|
|
37
|
+
* `now` / `cwd` / `version` parameters — given identical inputs and
|
|
38
|
+
* identical disk state, produces identical output. The orchestrator
|
|
39
|
+
* supplies these explicitly so tests can drive them deterministically.
|
|
40
|
+
*/
|
|
41
|
+
async function runScans(input) {
|
|
42
|
+
const fsImpl = input.fsImpl ?? node_fs_1.promises;
|
|
43
|
+
const [hoursSinceLastSession, update] = await Promise.all([
|
|
44
|
+
scanLastSessionEnd(input.paths, input.now, fsImpl),
|
|
45
|
+
scanUpdate(input.paths, input.version, fsImpl),
|
|
46
|
+
]);
|
|
47
|
+
return {
|
|
48
|
+
hourOfDay: input.now.getHours(),
|
|
49
|
+
cwdChanged: scanCwd(input.cwd, input.history),
|
|
50
|
+
cwd: input.cwd,
|
|
51
|
+
hoursSinceLastSession,
|
|
52
|
+
update,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// ── Individual scanners — exported for fine-grained unit tests --------
|
|
56
|
+
/** True iff cwd differs from history.lastCwd. False when history has no
|
|
57
|
+
* prior cwd (treats first-seen-cwd as "not changed"). */
|
|
58
|
+
function scanCwd(cwd, history) {
|
|
59
|
+
if (!history.lastCwd)
|
|
60
|
+
return false;
|
|
61
|
+
return node_path_1.default.resolve(history.lastCwd) !== node_path_1.default.resolve(cwd);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Hours since the most recent distillation file (mtime). Returns null
|
|
65
|
+
* when the distillations directory is missing or empty — caller treats
|
|
66
|
+
* null as "no prior session to remember".
|
|
67
|
+
*
|
|
68
|
+
* Reads directory entries, takes the newest mtime, returns elapsed
|
|
69
|
+
* hours rounded to nearest int. Hard-caps at 100 entries scanned —
|
|
70
|
+
* if the user has thousands of distillations the cost stays bounded
|
|
71
|
+
* (we only care about the newest; sorting is fine on 100 entries).
|
|
72
|
+
*/
|
|
73
|
+
async function scanLastSessionEnd(paths, now, fsImpl = node_fs_1.promises) {
|
|
74
|
+
const dir = node_path_1.default.join(paths.root, 'distillations');
|
|
75
|
+
let entries;
|
|
76
|
+
try {
|
|
77
|
+
entries = await fsImpl.readdir(dir);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null; // dir missing → no prior session
|
|
81
|
+
}
|
|
82
|
+
if (entries.length === 0)
|
|
83
|
+
return null;
|
|
84
|
+
// Cap at 100 — newest-mtime extraction; we only need the max.
|
|
85
|
+
const scanList = entries.slice(0, 100);
|
|
86
|
+
let newest = 0;
|
|
87
|
+
for (const e of scanList) {
|
|
88
|
+
try {
|
|
89
|
+
const st = await fsImpl.stat(node_path_1.default.join(dir, e));
|
|
90
|
+
if (st.mtimeMs > newest)
|
|
91
|
+
newest = st.mtimeMs;
|
|
92
|
+
}
|
|
93
|
+
catch { /* skip unreadable entry */ }
|
|
94
|
+
}
|
|
95
|
+
if (newest === 0)
|
|
96
|
+
return null;
|
|
97
|
+
const elapsedMs = now.getTime() - newest;
|
|
98
|
+
return Math.max(0, Math.round(elapsedMs / (1000 * 60 * 60)));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Read the existing update-status cache (written by the background
|
|
102
|
+
* checkUpdate flow). Returns the update info when `latest > installed`,
|
|
103
|
+
* null otherwise. NEVER hits the network — the greeter consumes
|
|
104
|
+
* whatever the boot-time update-check already cached.
|
|
105
|
+
*
|
|
106
|
+
* Cache shape (per core/v4/update/checkUpdate.ts contract):
|
|
107
|
+
* { latest: string, lastCheckedAt: string, ... }
|
|
108
|
+
* We read minimally — just `latest`. If parsing fails, return null
|
|
109
|
+
* (don't speculate about an update we can't confirm).
|
|
110
|
+
*/
|
|
111
|
+
async function scanUpdate(paths, version, fsImpl = node_fs_1.promises) {
|
|
112
|
+
const cachePath = node_path_1.default.join(paths.root, '.update_check.json');
|
|
113
|
+
try {
|
|
114
|
+
const raw = await fsImpl.readFile(cachePath, 'utf8');
|
|
115
|
+
const parsed = JSON.parse(raw);
|
|
116
|
+
if (!parsed.latest || typeof parsed.latest !== 'string')
|
|
117
|
+
return null;
|
|
118
|
+
if (!isNewer(parsed.latest, version))
|
|
119
|
+
return null;
|
|
120
|
+
return { latest: parsed.latest, installed: version };
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Returns true iff `a > b` under dot-numeric semver. Local copy so
|
|
127
|
+
* scan has no dependency on history's identical helper. */
|
|
128
|
+
function isNewer(a, b) {
|
|
129
|
+
const pa = a.split('.').map((s) => Number(s) || 0);
|
|
130
|
+
const pb = b.split('.').map((s) => Number(s) || 0);
|
|
131
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
132
|
+
const va = pa[i] ?? 0;
|
|
133
|
+
const vb = pb[i] ?? 0;
|
|
134
|
+
if (va > vb)
|
|
135
|
+
return true;
|
|
136
|
+
if (va < vb)
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* cli/v4/greeter/selectOffer.ts — v4.9.3 SLICE 1a.
|
|
10
|
+
*
|
|
11
|
+
* Pure-function priority selector. Given the post-reconcile scan +
|
|
12
|
+
* history + (optional) distillation snippet, returns at most one
|
|
13
|
+
* `Offer` to render. Returns null when nothing wins (silence rule).
|
|
14
|
+
*
|
|
15
|
+
* Tier ordering: 1 > 2 > 3 > 4. Within a tier, the first detected
|
|
16
|
+
* candidate wins (no scoring beyond the order listed below).
|
|
17
|
+
*
|
|
18
|
+
* Decay (applied per tier): an offer whose `id` exists in history.offers
|
|
19
|
+
* with response === 'ignored' AND whose offeredAt is newer than the
|
|
20
|
+
* per-tier window is SUPPRESSED. Exception: welcome-back has no decay —
|
|
21
|
+
* it always fires when the threshold is crossed.
|
|
22
|
+
*/
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.selectOffer = selectOffer;
|
|
25
|
+
const types_1 = require("./types");
|
|
26
|
+
const templates_1 = require("./templates");
|
|
27
|
+
function selectOffer(input) {
|
|
28
|
+
// Greeter respects the kill switch absolutely.
|
|
29
|
+
if (input.history.disabled)
|
|
30
|
+
return null;
|
|
31
|
+
const today = isoDateLocal(input.now);
|
|
32
|
+
// ── Tier 2: continuity ----------------------------------------------
|
|
33
|
+
// The orchestrator wires open_items + decisions from the most-recent
|
|
34
|
+
// distillation. Prefer open-item over decision (open work is more
|
|
35
|
+
// actionable; closed decisions are recap).
|
|
36
|
+
if (input.openItem && input.openItem.length > 0) {
|
|
37
|
+
return buildOffer('continuity-open-item', 2, undefined, {
|
|
38
|
+
openItem: input.openItem,
|
|
39
|
+
}, input);
|
|
40
|
+
}
|
|
41
|
+
if (input.lastDecision && input.lastDecision.length > 0) {
|
|
42
|
+
return buildOffer('continuity-decision', 2, undefined, {
|
|
43
|
+
decision: input.lastDecision,
|
|
44
|
+
}, input);
|
|
45
|
+
}
|
|
46
|
+
// welcome-back: always fires when hoursSinceLastSession >= 24, no
|
|
47
|
+
// decay. (Per dispatch: not really an offer — a continuity signal.)
|
|
48
|
+
if (input.scan.hoursSinceLastSession !== null &&
|
|
49
|
+
input.scan.hoursSinceLastSession >= types_1.WELCOME_BACK_THRESHOLD_HOURS) {
|
|
50
|
+
return buildOffer('welcome-back', 2, undefined, {
|
|
51
|
+
hoursAgo: input.scan.hoursSinceLastSession,
|
|
52
|
+
}, input);
|
|
53
|
+
}
|
|
54
|
+
// ── Tier 3: environment ---------------------------------------------
|
|
55
|
+
// Both gated on no-tier-2-fired (handled implicitly by being later in
|
|
56
|
+
// the function) AND not-in-3-day-decay-window.
|
|
57
|
+
if (input.scan.hourOfDay >= 18) {
|
|
58
|
+
const id = `time-of-day-evening-${today}`;
|
|
59
|
+
if (!isDecayedRecently(id, input.history, types_1.DECAY_DAYS_ENVIRONMENT, input.now)) {
|
|
60
|
+
return buildOffer('time-of-day-evening', 3, undefined, {}, input, id);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (input.scan.cwdChanged) {
|
|
64
|
+
const id = `cwd-changed-${today}`;
|
|
65
|
+
if (!isDecayedRecently(id, input.history, types_1.DECAY_DAYS_ENVIRONMENT, input.now)) {
|
|
66
|
+
return buildOffer('cwd-changed', 3, undefined, {
|
|
67
|
+
cwd: input.scan.cwd,
|
|
68
|
+
previousCwd: input.history.lastCwd,
|
|
69
|
+
}, input, id);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// ── Tier 4: update --------------------------------------------------
|
|
73
|
+
if (input.scan.update) {
|
|
74
|
+
const id = `update-available-${input.scan.update.latest}`;
|
|
75
|
+
if (!isDecayedRecently(id, input.history, types_1.DECAY_DAYS_UPDATE, input.now)) {
|
|
76
|
+
return buildOffer('update-available', 4, '/update install', {
|
|
77
|
+
installed: input.scan.update.installed,
|
|
78
|
+
latest: input.scan.update.latest,
|
|
79
|
+
}, input, id);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null; // silence rule
|
|
83
|
+
}
|
|
84
|
+
// ── helpers ----------------------------------------------------------
|
|
85
|
+
/**
|
|
86
|
+
* True iff history contains an `ignored` record for `id` whose age is
|
|
87
|
+
* within the decay window. Pending offers do NOT suppress — only
|
|
88
|
+
* ignored ones do (caller has logic for re-firing if the user just
|
|
89
|
+
* didn't see it).
|
|
90
|
+
*/
|
|
91
|
+
function isDecayedRecently(id, history, days, now) {
|
|
92
|
+
const cutoffMs = now.getTime() - days * 24 * 60 * 60 * 1000;
|
|
93
|
+
return history.offers.some((o) => o.id === id &&
|
|
94
|
+
o.response === 'ignored' &&
|
|
95
|
+
Date.parse(o.offeredAt) >= cutoffMs);
|
|
96
|
+
}
|
|
97
|
+
/** YYYY-MM-DD in the local timezone (matches the "good evening at 6pm
|
|
98
|
+
* local time" intent of the time-of-day scanner). */
|
|
99
|
+
function isoDateLocal(d) {
|
|
100
|
+
const y = d.getFullYear();
|
|
101
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
102
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
103
|
+
return `${y}-${m}-${dd}`;
|
|
104
|
+
}
|
|
105
|
+
function buildOffer(templateId, tier, expectedAction, data, input, customId) {
|
|
106
|
+
const ctx = {
|
|
107
|
+
...data,
|
|
108
|
+
paintMuted: input.paintMuted,
|
|
109
|
+
paintAccent: input.paintAccent,
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
id: customId ?? `${templateId}-${isoDateLocal(input.now)}`,
|
|
113
|
+
templateId,
|
|
114
|
+
tier,
|
|
115
|
+
expectedAction,
|
|
116
|
+
speech: templates_1.TEMPLATES[templateId](ctx),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* cli/v4/greeter/templates.ts — v4.9.3 SLICE 1a.
|
|
10
|
+
*
|
|
11
|
+
* Pure-function templates per TemplateId. Identical ctx ⇒ identical
|
|
12
|
+
* string out. No clock peek, no randomness, no env reads inside —
|
|
13
|
+
* every dynamic value arrives via the TemplateContext bag, including
|
|
14
|
+
* the two paint helpers.
|
|
15
|
+
*
|
|
16
|
+
* Render-site indent (2 spaces) and trailing newline are added by the
|
|
17
|
+
* orchestrator, NOT by these templates. Templates return one logical
|
|
18
|
+
* line of speech.
|
|
19
|
+
*/
|
|
20
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
21
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
22
|
+
};
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.TEMPLATES = void 0;
|
|
25
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
26
|
+
/**
|
|
27
|
+
* The eight templates. Tier-1 entries (daemon-crashed, hook-auto-disabled)
|
|
28
|
+
* are forward declarations — Slice 1's selectOffer never picks them. They
|
|
29
|
+
* exist so v4.10's tier-1 scanners have a typed home to drop offers into.
|
|
30
|
+
*/
|
|
31
|
+
exports.TEMPLATES = {
|
|
32
|
+
// ── Tier 1 (stubs — scanners deferred to v4.10) ----------------------
|
|
33
|
+
'daemon-crashed': (ctx) => `Daemon crashed mid-session. ${ctx.paintAccent('/daemon doctor')} for the postmortem.`,
|
|
34
|
+
'hook-auto-disabled': (ctx) => `A hook auto-disabled after repeated failures. ${ctx.paintAccent('/hooks audit')} for details.`,
|
|
35
|
+
// ── Tier 2 (continuity) ----------------------------------------------
|
|
36
|
+
'continuity-open-item': (ctx) => `Last session left this open: ${ctx.paintMuted(`"${ctx.openItem ?? ''}"`)}.`,
|
|
37
|
+
'continuity-decision': (ctx) => `Last session: ${ctx.paintMuted(ctx.decision ?? '')}.`,
|
|
38
|
+
'welcome-back': (ctx) => `Welcome back. Last session ended ${ctx.hoursAgo ?? 0}h ago.`,
|
|
39
|
+
// ── Tier 3 (environment) ---------------------------------------------
|
|
40
|
+
'time-of-day-evening': (_ctx) => `Good evening.`,
|
|
41
|
+
'cwd-changed': (ctx) => {
|
|
42
|
+
// Per user's prose suggestion: avoid "now" (implies temporal change).
|
|
43
|
+
// Phrasing: "In <basename> this time (last session: <previous>)."
|
|
44
|
+
const cur = ctx.cwd ? node_path_1.default.basename(ctx.cwd) : '';
|
|
45
|
+
const prv = ctx.previousCwd ? node_path_1.default.basename(ctx.previousCwd) : '';
|
|
46
|
+
return `In ${ctx.paintAccent(cur)} this time (last session: ${ctx.paintMuted(prv)}).`;
|
|
47
|
+
},
|
|
48
|
+
// ── Tier 4 (update) --------------------------------------------------
|
|
49
|
+
'update-available': (ctx) => `aiden-runtime ${ctx.installed ?? '?'} → ${ctx.latest ?? '?'} available. ` +
|
|
50
|
+
`${ctx.paintAccent('/update install')} to ship.`,
|
|
51
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* cli/v4/greeter/types.ts — v4.9.3 SLICE 1a.
|
|
10
|
+
*
|
|
11
|
+
* Shared types for the boot greeter. Kept in one file so the rest of
|
|
12
|
+
* the module imports a single typed surface — no circular-import risk
|
|
13
|
+
* when scan / history / selectOffer / templates reference each other.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.WELCOME_BACK_THRESHOLD_HOURS = exports.DECAY_DAYS_ENVIRONMENT = exports.DECAY_DAYS_UPDATE = void 0;
|
|
17
|
+
// ── Decay windows -------------------------------------------------------
|
|
18
|
+
/** Days an "ignored" update offer remains suppressed. */
|
|
19
|
+
exports.DECAY_DAYS_UPDATE = 7;
|
|
20
|
+
/** Days an "ignored" environment offer (cwd, time-of-day) remains suppressed. */
|
|
21
|
+
exports.DECAY_DAYS_ENVIRONMENT = 3;
|
|
22
|
+
/** Hours since last session before welcome-back fires. */
|
|
23
|
+
exports.WELCOME_BACK_THRESHOLD_HOURS = 24;
|
|
@@ -80,6 +80,9 @@ exports.AidenAgent = void 0;
|
|
|
80
80
|
// AIDEN_TCE=0 to disable. Zero
|
|
81
81
|
// behavioral change when unset. See core/v4/turnState.ts.
|
|
82
82
|
const turnState_1 = require("./turnState");
|
|
83
|
+
// v4.9.4 Slice 1 — tool-call/result protocol invariant + synthetic
|
|
84
|
+
// blocked-result helpers used at the surface + abort fill sites.
|
|
85
|
+
const toolCallInvariant_1 = require("./toolCallInvariant");
|
|
83
86
|
// v4.2 Phase 1 — per-tool result verifier. Same TCE gate as
|
|
84
87
|
// TurnState (default ON, opt-out via AIDEN_TCE=0); classification
|
|
85
88
|
// feeds the recovery controller.
|
|
@@ -152,6 +155,7 @@ class AidenAgent {
|
|
|
152
155
|
this.provider = opts.provider;
|
|
153
156
|
this.toolExecutor = opts.toolExecutor;
|
|
154
157
|
this.tools = opts.tools;
|
|
158
|
+
this.turnStateFactory = opts.turnStateFactory;
|
|
155
159
|
this.maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
156
160
|
this.fallback = opts.fallback;
|
|
157
161
|
this.onToolCall = opts.onToolCall;
|
|
@@ -640,7 +644,9 @@ class AidenAgent {
|
|
|
640
644
|
// When disabled, TurnState.recordToolCall short-circuits with
|
|
641
645
|
// `{kind: 'allow'}` and the entire v4.2 recovery surface stays
|
|
642
646
|
// dormant (zero behavioural change vs v4.1.6).
|
|
643
|
-
|
|
647
|
+
// v4.9.4 Slice 1 — honor optional test-seam factory. Production
|
|
648
|
+
// paths never pass turnStateFactory → falls through to real ctor.
|
|
649
|
+
const turnState = this.turnStateFactory?.() ?? new turnState_1.TurnState();
|
|
644
650
|
// v4.2 Phase 1 — per-tool verifier registry. Constructed
|
|
645
651
|
// unconditionally (cheap, no side effects) but only used to
|
|
646
652
|
// classify tool outcomes when TCE is enabled; verification args
|
|
@@ -850,13 +856,27 @@ class AidenAgent {
|
|
|
850
856
|
// TurnState internals + pushes a corrective system message,
|
|
851
857
|
// then continues the outer iteration loop from a clean baseline.
|
|
852
858
|
let rollbackDecision = null;
|
|
853
|
-
|
|
859
|
+
// v4.9.4 Slice 1 — `.entries()` so the surface + abort fill sites
|
|
860
|
+
// can slice from `callIndex + 1` to compute the un-dispatched tail.
|
|
861
|
+
for (const [callIndex, call] of output.toolCalls.entries()) {
|
|
854
862
|
// v4.6 prep — pre-tool-call cooperative-cancellation check.
|
|
855
863
|
// If the caller aborted between the model emitting tool calls
|
|
856
864
|
// and us dispatching them, skip the remaining calls in this
|
|
857
865
|
// batch. We set finishReason here; the outer-while break is
|
|
858
866
|
// handled after the for-of exits.
|
|
859
867
|
if (runOptions.signal?.aborted) {
|
|
868
|
+
// v4.9.4 Slice 1 — fill synthetic results so the assistant's
|
|
869
|
+
// toolCalls[] is balanced before we break. `call` (the one we
|
|
870
|
+
// were ABOUT to dispatch) gets variant='interrupted'; every
|
|
871
|
+
// remaining call gets variant='skipped'. Both with reason
|
|
872
|
+
// 'cancelled'. CRITICAL: also push turnToolMessages into the
|
|
873
|
+
// history NOW — the outer `if (finishReason === 'interrupted')`
|
|
874
|
+
// break (post-for-of) exits before reaching the line 1599
|
|
875
|
+
// bulk-push. Without this explicit push the synthetic results
|
|
876
|
+
// we just collected get discarded.
|
|
877
|
+
turnToolMessages.push((0, toolCallInvariant_1.synthesizeBlockedToolResult)(call, 'cancelled', { variant: 'interrupted' }));
|
|
878
|
+
(0, toolCallInvariant_1.fillRemainingAsBlocked)(turnToolMessages, output.toolCalls, callIndex + 1, 'cancelled', 'skipped');
|
|
879
|
+
messages.push(...turnToolMessages);
|
|
860
880
|
finishReason = 'interrupted';
|
|
861
881
|
finalContent = '';
|
|
862
882
|
break;
|
|
@@ -1084,9 +1104,22 @@ class AidenAgent {
|
|
|
1084
1104
|
}
|
|
1085
1105
|
else if (recovery.kind === 'surface' && recovery.surfaceCard) {
|
|
1086
1106
|
// Stage 3: structured failure. Stop dispatching the rest of
|
|
1087
|
-
// the batch — anything else is throwing good budget after
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1107
|
+
// the batch — anything else is throwing good budget after bad.
|
|
1108
|
+
// The outer loop reads `surfaceDecision` below and exits cleanly.
|
|
1109
|
+
//
|
|
1110
|
+
// v4.9.4 Slice 1 — BEFORE breaking, fill synthetic blocked-
|
|
1111
|
+
// tool-result messages for every un-dispatched call in this
|
|
1112
|
+
// batch (slice from callIndex+1; the current call already had
|
|
1113
|
+
// its real result pushed at line ~1440 just above). Without
|
|
1114
|
+
// this fill, the assistant message at line ~1170 carries
|
|
1115
|
+
// tool_call_ids whose matching tool results never land in
|
|
1116
|
+
// history. The outer surfaceDecision branch (line ~1573)
|
|
1117
|
+
// pushes turnToolMessages into `messages` and breaks the
|
|
1118
|
+
// outer while loop, ending the turn — but the persisted
|
|
1119
|
+
// history carries the orphans. A resumed conversation (or
|
|
1120
|
+
// any second provider call in the same turn) then returns
|
|
1121
|
+
// 400 "No tool output found for function call <id>".
|
|
1122
|
+
(0, toolCallInvariant_1.fillRemainingAsBlocked)(turnToolMessages, output.toolCalls, callIndex + 1, 'tool_loop_surface');
|
|
1090
1123
|
surfaceDecision = recovery;
|
|
1091
1124
|
break;
|
|
1092
1125
|
}
|
|
@@ -1211,6 +1244,15 @@ class AidenAgent {
|
|
|
1211
1244
|
* loop sees the same `ProviderCallOutput` regardless.
|
|
1212
1245
|
*/
|
|
1213
1246
|
async callProvider(messages, tools, runOptions) {
|
|
1247
|
+
// v4.9.4 Slice 1 — tool-call protocol preflight. Every assistant
|
|
1248
|
+
// toolCalls[] entry must have a matching {role:'tool', toolCallId}
|
|
1249
|
+
// BEFORE shipping to any provider. If this throws, a guard in
|
|
1250
|
+
// runTurnLoop is leaking orphan tool_call_ids — find the culprit,
|
|
1251
|
+
// don't catch this. The surface + abort fill sites above already
|
|
1252
|
+
// satisfy the invariant; preflight is the audit-loud safety net
|
|
1253
|
+
// for new guards added later (v4.10 rate-limit / cost-budget /
|
|
1254
|
+
// hook-deny). See core/v4/toolCallInvariant.ts.
|
|
1255
|
+
(0, toolCallInvariant_1.assertNoUnansweredToolCalls)(messages);
|
|
1214
1256
|
const wantStream = runOptions.stream === true && typeof this.provider.callStream === 'function';
|
|
1215
1257
|
// v4.1.5 Issue K — fire just before the HTTP request opens, so the
|
|
1216
1258
|
// display layer can transition the activity verb from local-prep
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* core/v4/toolCallInvariant.ts — v4.9.4 SLICE 1.
|
|
10
|
+
*
|
|
11
|
+
* The tool-call/tool-result protocol invariant required by the OpenAI /
|
|
12
|
+
* ChatGPT-Plus / Anthropic / Codex Responses message wire formats:
|
|
13
|
+
*
|
|
14
|
+
* For every assistant message with toolCalls[],
|
|
15
|
+
* every tool_call.id MUST be answered by a later `tool` role message
|
|
16
|
+
* carrying the same toolCallId, before the next provider request.
|
|
17
|
+
*
|
|
18
|
+
* Aiden previously violated this in two known dispatch sites
|
|
19
|
+
* (aidenAgent runTurnLoop's surfaceDecision break + abort-signal break)
|
|
20
|
+
* which left orphan tool_call_ids in persisted history. Resuming such
|
|
21
|
+
* a history triggered 400 from the provider:
|
|
22
|
+
*
|
|
23
|
+
* Provider chatgpt-plus request failed (400):
|
|
24
|
+
* No tool output found for function call call_<id>.
|
|
25
|
+
*
|
|
26
|
+
* This module exposes three primitives:
|
|
27
|
+
* - assertNoUnansweredToolCalls(messages) — preflight gate
|
|
28
|
+
* - synthesizeBlockedToolResult(call, reason) — fill primitive
|
|
29
|
+
* - fillRemainingAsBlocked(buf, calls, idx, ..) — batch helper
|
|
30
|
+
*
|
|
31
|
+
* Plus the OrphanToolCallError class thrown by the preflight.
|
|
32
|
+
*
|
|
33
|
+
* Provider-agnostic — each adapter translates Aiden's internal Message
|
|
34
|
+
* type into its native wire shape. Assertions run against the internal
|
|
35
|
+
* Message shape itself.
|
|
36
|
+
*/
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
exports.OrphanToolCallError = void 0;
|
|
39
|
+
exports.assertNoUnansweredToolCalls = assertNoUnansweredToolCalls;
|
|
40
|
+
exports.synthesizeBlockedToolResult = synthesizeBlockedToolResult;
|
|
41
|
+
exports.fillRemainingAsBlocked = fillRemainingAsBlocked;
|
|
42
|
+
// ── Error class ──────────────────────────────────────────────────────
|
|
43
|
+
/**
|
|
44
|
+
* Thrown by assertNoUnansweredToolCalls. Subclassed from Error so
|
|
45
|
+
* triage code can:
|
|
46
|
+
*
|
|
47
|
+
* try { ... } catch (e) {
|
|
48
|
+
* if (e instanceof OrphanToolCallError) { ... }
|
|
49
|
+
* }
|
|
50
|
+
*
|
|
51
|
+
* Production code MUST NOT catch this. If it fires, a guard upstream
|
|
52
|
+
* is leaking orphan tool_call_ids and we want the failure loud at the
|
|
53
|
+
* site that introduced the leak.
|
|
54
|
+
*/
|
|
55
|
+
class OrphanToolCallError extends Error {
|
|
56
|
+
constructor(orphans) {
|
|
57
|
+
const ids = orphans.map((o) => `${o.toolName}#${o.toolCallId}`).join(', ');
|
|
58
|
+
super(`Tool-call/result protocol violated: ${orphans.length} unanswered tool_call_id(s) [${ids}]. ` +
|
|
59
|
+
`Some guard in the dispatch loop emitted an assistant message with tool_calls[] ` +
|
|
60
|
+
`but did not push a matching {role:'tool', toolCallId} for every id. ` +
|
|
61
|
+
`Find the guard and add a synthesizeBlockedToolResult() call before its break/continue.`);
|
|
62
|
+
this.name = 'OrphanToolCallError';
|
|
63
|
+
this.orphans = orphans;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
exports.OrphanToolCallError = OrphanToolCallError;
|
|
67
|
+
// ── Preflight assertion ──────────────────────────────────────────────
|
|
68
|
+
/**
|
|
69
|
+
* Walk the messages once. For each assistant message at index i, scan
|
|
70
|
+
* messages[i+1..] for `{ role: 'tool', toolCallId }` entries matching
|
|
71
|
+
* each toolCalls[].id. Orphans (unmatched ids) accumulate; a single
|
|
72
|
+
* Error is thrown listing all of them so a single debugging session
|
|
73
|
+
* sees the full damage (better than throw-on-first).
|
|
74
|
+
*
|
|
75
|
+
* Pure. No IO, no clock. Cost is O(N*M) where N = total messages and
|
|
76
|
+
* M = avg tool-calls-per-assistant-turn; trivial for any realistic
|
|
77
|
+
* session (low hundreds of messages, low tens of tool calls per turn).
|
|
78
|
+
*
|
|
79
|
+
* Called from AidenAgent.callProvider() as the single boundary preflight
|
|
80
|
+
* — every provider adapter receives messages[] through that one funnel.
|
|
81
|
+
*/
|
|
82
|
+
function assertNoUnansweredToolCalls(messages) {
|
|
83
|
+
// Collect all tool-result ids first (single pass) so we can resolve
|
|
84
|
+
// each assistant's tool_calls in O(1) against a Set.
|
|
85
|
+
const answeredIds = new Set();
|
|
86
|
+
for (const m of messages) {
|
|
87
|
+
if (m.role === 'tool')
|
|
88
|
+
answeredIds.add(m.toolCallId);
|
|
89
|
+
}
|
|
90
|
+
// Now walk assistants and collect orphans.
|
|
91
|
+
const orphans = [];
|
|
92
|
+
for (const m of messages) {
|
|
93
|
+
if (m.role !== 'assistant' || !m.toolCalls)
|
|
94
|
+
continue;
|
|
95
|
+
for (const tc of m.toolCalls) {
|
|
96
|
+
if (!answeredIds.has(tc.id)) {
|
|
97
|
+
orphans.push({ toolCallId: tc.id, toolName: tc.name });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (orphans.length > 0)
|
|
102
|
+
throw new OrphanToolCallError(orphans);
|
|
103
|
+
}
|
|
104
|
+
// ── Synthesis primitives ─────────────────────────────────────────────
|
|
105
|
+
/**
|
|
106
|
+
* Build a tool-role message whose content is a JSON-stringified failure
|
|
107
|
+
* object the LLM can parse:
|
|
108
|
+
*
|
|
109
|
+
* { ok: false, blocked: true, reason: <code>, message: <human> }
|
|
110
|
+
*
|
|
111
|
+
* Same shape regardless of which guard fired so the LLM sees a uniform
|
|
112
|
+
* signal. Internal Aiden Message type — providers/v4 adapters handle
|
|
113
|
+
* wire-shape translation per their native protocol.
|
|
114
|
+
*/
|
|
115
|
+
function synthesizeBlockedToolResult(call, reason, opts = {}) {
|
|
116
|
+
const variant = opts.variant ?? 'skipped';
|
|
117
|
+
const humanMessage = variant === 'interrupted'
|
|
118
|
+
? `This call was interrupted before execution. (reason: ${reason})`
|
|
119
|
+
: `This call was skipped because the turn was cancelled. (reason: ${reason})`;
|
|
120
|
+
// tool_loop_surface variant is always 'skipped' semantically (we
|
|
121
|
+
// already executed the call before the surface decision fired, so
|
|
122
|
+
// the SKIPPED calls are the remainder). But we still let the caller
|
|
123
|
+
// override if a future site has a different shape.
|
|
124
|
+
const content = JSON.stringify({
|
|
125
|
+
ok: false,
|
|
126
|
+
blocked: true,
|
|
127
|
+
reason,
|
|
128
|
+
message: humanMessage,
|
|
129
|
+
});
|
|
130
|
+
return {
|
|
131
|
+
role: 'tool',
|
|
132
|
+
toolCallId: call.id,
|
|
133
|
+
content,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Push synthetic blocked-tool-result messages for every unprocessed
|
|
138
|
+
* call from `startIdx` (inclusive) onward. Mutates `buf` in place
|
|
139
|
+
* (matches the existing turnToolMessages accumulator pattern in
|
|
140
|
+
* aidenAgent.ts; pure-returning would force a spread at every call
|
|
141
|
+
* site).
|
|
142
|
+
*
|
|
143
|
+
* Exported because v4.10 guards (rate-limit, cost-budget, hook-deny)
|
|
144
|
+
* will want the same shape.
|
|
145
|
+
*/
|
|
146
|
+
function fillRemainingAsBlocked(buf, toolCalls, startIdx, reason, variant = 'skipped') {
|
|
147
|
+
for (let i = startIdx; i < toolCalls.length; i++) {
|
|
148
|
+
buf.push(synthesizeBlockedToolResult(toolCalls[i], reason, { variant }));
|
|
149
|
+
}
|
|
150
|
+
}
|