aiden-runtime 4.9.2 → 4.9.3

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 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
- ![Aiden in action](docs/screenshots/autonomy.png)
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
+ https://github.com/user-attachments/assets/a76bf4a5-28ca-43b5-8975-5ef0a66ee90d
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiden-runtime",
3
- "version": "4.9.2",
3
+ "version": "4.9.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },