aiden-runtime 4.9.1 → 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.
Files changed (41) hide show
  1. package/README.md +47 -1
  2. package/dist/cli/v4/aidenPrompt.js +12 -0
  3. package/dist/cli/v4/chatSession.js +41 -13
  4. package/dist/cli/v4/commands/channel.js +4 -6
  5. package/dist/cli/v4/commands/cron.js +6 -1
  6. package/dist/cli/v4/commands/daemonDoctor.js +5 -5
  7. package/dist/cli/v4/commands/daemonStatus.js +1 -1
  8. package/dist/cli/v4/commands/greeter.js +86 -0
  9. package/dist/cli/v4/commands/help.js +2 -0
  10. package/dist/cli/v4/commands/index.js +4 -0
  11. package/dist/cli/v4/commands/mcp.js +2 -2
  12. package/dist/cli/v4/commands/plugins.js +4 -6
  13. package/dist/cli/v4/commands/trigger.js +18 -18
  14. package/dist/cli/v4/confirmPrompt.js +67 -0
  15. package/dist/cli/v4/greeter/history.js +134 -0
  16. package/dist/cli/v4/greeter/index.js +147 -0
  17. package/dist/cli/v4/greeter/scan.js +140 -0
  18. package/dist/cli/v4/greeter/selectOffer.js +118 -0
  19. package/dist/cli/v4/greeter/templates.js +51 -0
  20. package/dist/cli/v4/greeter/types.js +23 -0
  21. package/dist/core/v4/daemon/db/migrations.js +398 -398
  22. package/dist/core/v4/daemon/idempotency/runIdempotencyStore.js +10 -10
  23. package/dist/core/v4/daemon/incarnationStore.js +9 -9
  24. package/dist/core/v4/daemon/runs/attemptStore.js +8 -8
  25. package/dist/core/v4/daemon/runs/reclaim.js +12 -12
  26. package/dist/core/v4/daemon/runs/stuckAttemptWatchdog.js +19 -19
  27. package/dist/core/v4/daemon/spans/spanStore.js +14 -14
  28. package/dist/core/v4/daemon/triggerBus.js +61 -61
  29. package/dist/core/v4/hooks/auditQuery.js +11 -11
  30. package/dist/core/v4/hooks/dispatcher.js +13 -13
  31. package/dist/core/v4/hooks/registry.js +8 -8
  32. package/dist/core/v4/mcp/transport.js +9 -9
  33. package/dist/core/v4/update/executeInstall.js +29 -18
  34. package/dist/core/v4/update/recoveryScript.js +70 -0
  35. package/dist/core/v4/util/spawnCommand.js +151 -0
  36. package/package.json +1 -1
  37. package/themes/default.yaml +52 -52
  38. package/themes/dracula.yaml +32 -32
  39. package/themes/light.yaml +32 -32
  40. package/themes/monochrome.yaml +31 -31
  41. package/themes/tokyo-night.yaml +32 -32
@@ -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;