aiden-runtime 4.0.1 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +11 -7
  2. package/config/hardware.json +2 -2
  3. package/dist/api/server.js +50 -52
  4. package/dist/cli/v4/aidenCLI.js +513 -14
  5. package/dist/cli/v4/aidenPrompt.js +317 -0
  6. package/dist/cli/v4/box.js +105 -39
  7. package/dist/cli/v4/callbacks.js +39 -6
  8. package/dist/cli/v4/chatSession.js +269 -52
  9. package/dist/cli/v4/citationFooter.js +97 -0
  10. package/dist/cli/v4/commands/channel.js +656 -0
  11. package/dist/cli/v4/commands/clear.js +1 -1
  12. package/dist/cli/v4/commands/compress.js +1 -1
  13. package/dist/cli/v4/commands/cron.js +44 -16
  14. package/dist/cli/v4/commands/fanout.js +236 -0
  15. package/dist/cli/v4/commands/help.js +15 -4
  16. package/dist/cli/v4/commands/history.js +84 -0
  17. package/dist/cli/v4/commands/index.js +19 -1
  18. package/dist/cli/v4/commands/mcp.js +358 -0
  19. package/dist/cli/v4/commands/setup.js +34 -0
  20. package/dist/cli/v4/commands/show.js +43 -0
  21. package/dist/cli/v4/commands/skills.js +169 -4
  22. package/dist/cli/v4/commands/status.js +84 -0
  23. package/dist/cli/v4/commands/subagent.js +78 -0
  24. package/dist/cli/v4/commands/verbose.js +1 -1
  25. package/dist/cli/v4/commands/voice.js +218 -0
  26. package/dist/cli/v4/cronCli.js +103 -0
  27. package/dist/cli/v4/display.js +300 -14
  28. package/dist/cli/v4/doctor.js +41 -0
  29. package/dist/cli/v4/envSources.js +105 -0
  30. package/dist/cli/v4/ghostMatch.js +74 -0
  31. package/dist/cli/v4/historyStore.js +163 -0
  32. package/dist/cli/v4/pasteCompression.js +124 -0
  33. package/dist/cli/v4/pasteIntercept.js +203 -0
  34. package/dist/cli/v4/replyRenderer.js +209 -0
  35. package/dist/cli/v4/resizeGuard.js +92 -0
  36. package/dist/cli/v4/setupWizard.js +466 -232
  37. package/dist/cli/v4/shellInterpolation.js +139 -0
  38. package/dist/cli/v4/skinEngine.js +21 -1
  39. package/dist/cli/v4/streamingPrefix.js +121 -0
  40. package/dist/cli/v4/syntaxHighlight.js +345 -0
  41. package/dist/cli/v4/table.js +216 -0
  42. package/dist/cli/v4/themeDetect.js +81 -0
  43. package/dist/cli/v4/uiBuild.js +74 -0
  44. package/dist/cli/v4/voiceCli.js +113 -0
  45. package/dist/cli/v4/voicePromptApi.js +196 -0
  46. package/dist/core/channels/discord.js +16 -10
  47. package/dist/core/channels/email.js +13 -9
  48. package/dist/core/channels/imessage.js +13 -9
  49. package/dist/core/channels/manager.js +25 -7
  50. package/dist/core/channels/pdf-extract.js +180 -0
  51. package/dist/core/channels/photo-vision.js +157 -0
  52. package/dist/core/channels/signal.js +11 -7
  53. package/dist/core/channels/slack.js +13 -10
  54. package/dist/core/channels/telegram-commands.js +154 -0
  55. package/dist/core/channels/telegram-groups.js +198 -0
  56. package/dist/core/channels/telegram-rate-limit.js +124 -0
  57. package/dist/core/channels/telegram.js +1980 -0
  58. package/dist/core/channels/twilio.js +11 -7
  59. package/dist/core/channels/webhook.js +9 -5
  60. package/dist/core/channels/whatsapp.js +15 -11
  61. package/dist/core/channels/whisper-transcribe.js +163 -0
  62. package/dist/core/cronManager.js +33 -294
  63. package/dist/core/gateway.js +29 -8
  64. package/dist/core/playwrightBridge.js +90 -0
  65. package/dist/core/v4/aidenAgent.js +35 -0
  66. package/dist/core/v4/auxiliaryClient.js +2 -2
  67. package/dist/core/v4/cron/atomicWrite.js +18 -4
  68. package/dist/core/v4/cron/cronExecute.js +300 -0
  69. package/dist/core/v4/cron/cronManager.js +502 -0
  70. package/dist/core/v4/cron/cronState.js +314 -0
  71. package/dist/core/v4/cron/cronTick.js +90 -0
  72. package/dist/core/v4/cron/diagnostics.js +104 -0
  73. package/dist/core/v4/cron/graceWindow.js +79 -0
  74. package/dist/core/v4/firstRun/providerDetection.js +287 -0
  75. package/dist/core/v4/logger/factory.js +110 -0
  76. package/dist/core/v4/logger/index.js +22 -0
  77. package/dist/core/v4/logger/logger.js +101 -0
  78. package/dist/core/v4/logger/sinks/fileSink.js +110 -0
  79. package/dist/core/v4/logger/sinks/multiSink.js +43 -0
  80. package/dist/core/v4/logger/sinks/nullSink.js +53 -0
  81. package/dist/core/v4/logger/sinks/stdSink.js +81 -0
  82. package/dist/core/v4/mcp/server/diagnostics.js +40 -0
  83. package/dist/core/v4/mcp/server/skillBridge.js +94 -0
  84. package/dist/core/v4/mcp/server/stdioServer.js +119 -0
  85. package/dist/core/v4/mcp/server/toolBridge.js +168 -0
  86. package/dist/core/v4/platformPaths.js +105 -0
  87. package/dist/core/v4/providerFallback.js +25 -0
  88. package/dist/core/v4/skillLoader.js +21 -5
  89. package/dist/core/v4/skillMining/candidateStore.js +164 -0
  90. package/dist/core/v4/skillMining/extractorPrompt.js +111 -0
  91. package/dist/core/v4/skillMining/proposalBuilder.js +139 -0
  92. package/dist/core/v4/skillMining/skillMiner.js +191 -0
  93. package/dist/core/v4/skillMining/traceFingerprint.js +51 -0
  94. package/dist/core/v4/subagent/budget.js +76 -0
  95. package/dist/core/v4/subagent/diagnostics.js +22 -0
  96. package/dist/core/v4/subagent/fanout.js +216 -0
  97. package/dist/core/v4/subagent/merger.js +148 -0
  98. package/dist/core/v4/subagent/providerRotation.js +54 -0
  99. package/dist/core/v4/voice/audioStream.js +373 -0
  100. package/dist/core/v4/voice/cliVoice.js +393 -0
  101. package/dist/core/v4/voice/diagnostics.js +66 -0
  102. package/dist/core/v4/voice/ttsStream.js +193 -0
  103. package/dist/core/version.js +1 -1
  104. package/dist/core/visionAnalyze.js +291 -90
  105. package/dist/core/voice/audio.js +61 -5
  106. package/dist/core/voice/audioBackend.js +134 -0
  107. package/dist/core/voice/stt.js +61 -6
  108. package/dist/core/voice/tts.js +19 -3
  109. package/dist/providers/v4/nullAdapter.js +58 -0
  110. package/dist/tools/v4/index.js +32 -1
  111. package/dist/tools/v4/subagent/subagentFanout.js +166 -0
  112. package/package.json +11 -2
@@ -412,6 +412,31 @@ class FallbackAdapter {
412
412
  }
413
413
  this.activeSlotId = slotId;
414
414
  }
415
+ /**
416
+ * Phase v4.1-subagent — clone the adapter with FRESH mutable state
417
+ * but SHARED slot configs. Subagent fanout uses this so each
418
+ * subagent gets its own rate-limit bookkeeping (slotState,
419
+ * cooldownUntil, requestCount, activeSlotId all reset) without
420
+ * paying for slot-config rebuilds. The slot list and the cooldownMs
421
+ * / clock / observer callbacks are read-only and safely shared
422
+ * across clones.
423
+ *
424
+ * One subagent hitting a 429 marks ITS clone's slot as cooled-down;
425
+ * the parent and sibling clones still see the slot as available.
426
+ * That's a deliberate tradeoff: tighter contention on the same
427
+ * provider key, but isolation prevents one slow subagent from
428
+ * starving siblings via parent-side cooldown state.
429
+ */
430
+ clone() {
431
+ return new FallbackAdapter({
432
+ apiMode: this.apiMode,
433
+ slots: this.slots,
434
+ cooldownMs: this.cooldownMs,
435
+ now: this.clock,
436
+ onRateLimit: this.onRateLimit,
437
+ onFallback: this.onFallback,
438
+ });
439
+ }
415
440
  /**
416
441
  * Diagnostic snapshot for `/providers`. Per-slot cooldown is reported
417
442
  * in seconds remaining (0 when the slot is fresh) so the slash command
@@ -74,15 +74,31 @@ class SkillLoader {
74
74
  return { ...this.lastCounts, skippedPaths: [...this.lastCounts.skippedPaths] };
75
75
  }
76
76
  async load(name) {
77
- // Honour the cache when available so we don't re-walk for a
78
- // single-skill lookup. Falls through to disk on a miss so newly
79
- // dropped skills still resolve in long-running processes (the cache
80
- // never sees them otherwise that's the whole point of `invalidate`).
77
+ // Phase v4.1-cross-platform: case-insensitive cache lookup so a
78
+ // skill registered as `WebSearch` resolves the same on Linux
79
+ // (case-sensitive FS) as on Windows (case-insensitive FS). The
80
+ // ON-DISK directory name still has to match in case for the
81
+ // disk-fallback path to work, so we ALSO loadAll first when the
82
+ // case-insensitive cache hit succeeds — that gives us the actual
83
+ // file-system path and the loader can serve it from cache.
84
+ const target = name.toLowerCase();
85
+ if (!this.cache) {
86
+ // Trigger a one-time scan so case-insensitive lookup has data.
87
+ try {
88
+ await this.loadAll();
89
+ }
90
+ catch { /* ignore — fall through to disk */ }
91
+ }
81
92
  if (this.cache) {
82
- const hit = this.cache.find((s) => s.frontmatter.name === name);
93
+ const hit = this.cache.find((s) => (s.frontmatter.name ?? '').toLowerCase() === target);
83
94
  if (hit)
84
95
  return hit;
85
96
  }
97
+ // Disk fallback: try the verbatim name, then a lowercase variant
98
+ // (covers case where the cache failed to populate but the on-disk
99
+ // dir uses lowercase). On case-sensitive filesystems neither will
100
+ // hit if the directory case doesn't match the requested name —
101
+ // that's fine, the cache lookup above is the authoritative path.
86
102
  const dirSkill = node_path_1.default.join(this.paths.skillsDir, name, 'SKILL.md');
87
103
  const fileSkill = node_path_1.default.join(this.paths.skillsDir, `${name}.md`);
88
104
  return (await this.tryParse(dirSkill)) ?? (await this.tryParse(fileSkill));
@@ -0,0 +1,164 @@
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/skillMining/candidateStore.ts — Phase v4.1-skill-mining
10
+ *
11
+ * Atomic JSON queue for mined skill candidates and rejection
12
+ * fingerprints. Two files under `<aidenHome>/skills/learned/`:
13
+ *
14
+ * - `.candidates.json` — pending review queue, append-only via
15
+ * this module. `/skills review` reads it; `/skills accept`
16
+ * and `/skills reject` mutate it.
17
+ *
18
+ * - `.rejected.json` — fingerprint set the dedup gate consults
19
+ * so a user-rejected workflow doesn't get re-proposed on the
20
+ * next matching turn. Keyed by fingerprint, not id, so the
21
+ * dedup survives across the candidate's lifecycle.
22
+ *
23
+ * Concurrency: a per-process write queue serialises every mutation
24
+ * (mirrors the BundledManifest pattern at
25
+ * `core/v4/skillBundledManifest.ts:50-53`). Cross-process safety
26
+ * is non-goal — the agent is a single-user CLI.
27
+ *
28
+ * Atomicity: writes go to a sibling `.tmp` file then `rename` over
29
+ * the live path. Windows rename is atomic on the same volume so a
30
+ * crash mid-write never leaves a partial JSON file.
31
+ */
32
+ var __importDefault = (this && this.__importDefault) || function (mod) {
33
+ return (mod && mod.__esModule) ? mod : { "default": mod };
34
+ };
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CandidateStore = void 0;
37
+ const node_fs_1 = require("node:fs");
38
+ const node_path_1 = __importDefault(require("node:path"));
39
+ const paths_1 = require("../paths");
40
+ const ENVELOPE_VERSION = 1;
41
+ class CandidateStore {
42
+ constructor() {
43
+ this.writeQueue = Promise.resolve();
44
+ }
45
+ /** `<aidenHome>/skills/learned/`. */
46
+ dir() {
47
+ return node_path_1.default.join((0, paths_1.resolveAidenPaths)().skillsDir, 'learned');
48
+ }
49
+ candidatesPath() {
50
+ return node_path_1.default.join(this.dir(), '.candidates.json');
51
+ }
52
+ rejectedPath() {
53
+ return node_path_1.default.join(this.dir(), '.rejected.json');
54
+ }
55
+ async ensureDir() {
56
+ await node_fs_1.promises.mkdir(this.dir(), { recursive: true });
57
+ }
58
+ async readJson(p, fallback) {
59
+ try {
60
+ const raw = await node_fs_1.promises.readFile(p, 'utf8');
61
+ return JSON.parse(raw);
62
+ }
63
+ catch {
64
+ return fallback;
65
+ }
66
+ }
67
+ /** Atomic `tmp` + rename. */
68
+ async writeJsonAtomic(p, data) {
69
+ await this.ensureDir();
70
+ const tmp = `${p}.tmp`;
71
+ await node_fs_1.promises.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
72
+ await node_fs_1.promises.rename(tmp, p);
73
+ }
74
+ /** Append a candidate to the pending queue. Returns the assigned id. */
75
+ async append(candidate) {
76
+ return new Promise((resolve, reject) => {
77
+ this.writeQueue = this.writeQueue.then(async () => {
78
+ try {
79
+ const env = await this.readJson(this.candidatesPath(), {
80
+ version: ENVELOPE_VERSION,
81
+ candidates: [],
82
+ });
83
+ env.version = ENVELOPE_VERSION;
84
+ env.candidates.push(candidate);
85
+ await this.writeJsonAtomic(this.candidatesPath(), env);
86
+ resolve(candidate);
87
+ }
88
+ catch (err) {
89
+ reject(err);
90
+ }
91
+ });
92
+ });
93
+ }
94
+ /** Read the full pending queue, newest last. */
95
+ async list() {
96
+ const env = await this.readJson(this.candidatesPath(), {
97
+ version: ENVELOPE_VERSION,
98
+ candidates: [],
99
+ });
100
+ return env.candidates ?? [];
101
+ }
102
+ /** Fetch a single candidate by id, or undefined. */
103
+ async get(id) {
104
+ const all = await this.list();
105
+ return all.find((c) => c.id === id);
106
+ }
107
+ /** Remove a candidate by id. No-op if missing. */
108
+ async remove(id) {
109
+ return new Promise((resolve, reject) => {
110
+ this.writeQueue = this.writeQueue.then(async () => {
111
+ try {
112
+ const env = await this.readJson(this.candidatesPath(), {
113
+ version: ENVELOPE_VERSION,
114
+ candidates: [],
115
+ });
116
+ env.version = ENVELOPE_VERSION;
117
+ env.candidates = env.candidates.filter((c) => c.id !== id);
118
+ await this.writeJsonAtomic(this.candidatesPath(), env);
119
+ resolve();
120
+ }
121
+ catch (err) {
122
+ reject(err);
123
+ }
124
+ });
125
+ });
126
+ }
127
+ /** Append a rejection fingerprint (with optional reason). */
128
+ async recordRejection(fingerprint, reason) {
129
+ return new Promise((resolve, reject) => {
130
+ this.writeQueue = this.writeQueue.then(async () => {
131
+ try {
132
+ const env = await this.readJson(this.rejectedPath(), {
133
+ version: ENVELOPE_VERSION,
134
+ rejected: [],
135
+ });
136
+ env.version = ENVELOPE_VERSION;
137
+ env.rejected.push({
138
+ fingerprint,
139
+ reason,
140
+ rejectedAt: new Date().toISOString(),
141
+ });
142
+ await this.writeJsonAtomic(this.rejectedPath(), env);
143
+ resolve();
144
+ }
145
+ catch (err) {
146
+ reject(err);
147
+ }
148
+ });
149
+ });
150
+ }
151
+ /** Return the set of rejected fingerprints (for dedup). */
152
+ async loadRejected() {
153
+ const env = await this.readJson(this.rejectedPath(), {
154
+ version: ENVELOPE_VERSION,
155
+ rejected: [],
156
+ });
157
+ return new Set((env.rejected ?? []).map((r) => r.fingerprint));
158
+ }
159
+ /** Test/reset hook: drop the in-process write queue. Disk untouched. */
160
+ _resetForTests() {
161
+ this.writeQueue = Promise.resolve();
162
+ }
163
+ }
164
+ exports.CandidateStore = CandidateStore;
@@ -0,0 +1,111 @@
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/skillMining/extractorPrompt.ts — Phase v4.1-skill-mining
10
+ *
11
+ * Optional LLM refinement pass on top of `proposalBuilder.draft()`.
12
+ * The skeleton SKILL.md the builder produces is already a valid
13
+ * v4 skill; the refiner only polishes prose. Two hard rules:
14
+ *
15
+ * 1. Structural fields are sacred — name, description, version,
16
+ * metadata.aiden.* must round-trip unchanged. We re-parse the
17
+ * refined output and discard it if any required field drifts.
18
+ *
19
+ * 2. Never write attribution tokens (hermes / nous / "portions
20
+ * adapted from" / "original copyright"). The permanent
21
+ * attribution sweep validates this; if a refined output
22
+ * contains any forbidden token, we fall back to the skeleton.
23
+ *
24
+ * If the auxiliary client is unavailable, the call times out, or
25
+ * the refined output fails validation, the function returns the
26
+ * skeleton unchanged. Mining works fully offline — refinement is
27
+ * best-effort polish.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.refine = refine;
31
+ const skillSpec_1 = require("../skillSpec");
32
+ const FORBIDDEN_TOKENS_RE = /\b(hermes|nous|portions adapted from|original copyright)\b/i;
33
+ const REFINER_SYSTEM_PROMPT = `
34
+ You polish auto-generated skill markdown for a local-first AI agent.
35
+
36
+ Your job is to improve the WORDING ONLY of an already-valid SKILL.md
37
+ file. The frontmatter (everything between the leading "---" markers)
38
+ must round-trip BYTE-FOR-BYTE unchanged. The "# <name>" heading must
39
+ stay first in the body. The numbered "## Steps" list must remain
40
+ numbered and in the same order; you may rephrase step descriptions
41
+ but must NOT add or remove steps.
42
+
43
+ Hard rules:
44
+ - Output the COMPLETE SKILL.md, not a diff.
45
+ - Do not add boilerplate, citations, or attribution to any other
46
+ agent or codebase. The skill is 100% the user's own.
47
+ - Do not introduce mock/fake values into commands.
48
+ - Keep total length under 6000 characters.
49
+ `.trim();
50
+ /**
51
+ * Refine `skeleton` via the auxiliary client. Always returns a
52
+ * valid SKILL.md string — either the refined output (if it passes
53
+ * round-trip + no-attribution validation) or the original skeleton.
54
+ */
55
+ async function refine(skeleton, opts = {}) {
56
+ const client = opts.client;
57
+ if (!client || client.isUnavailable()) {
58
+ return skeleton;
59
+ }
60
+ // Parse the skeleton up front to lock the canonical frontmatter
61
+ // shape we'll require the refined output to match.
62
+ let skeletonParsed;
63
+ try {
64
+ skeletonParsed = (0, skillSpec_1.parseSkillContent)(skeleton);
65
+ }
66
+ catch {
67
+ // If the skeleton itself is invalid, refining can only make it
68
+ // worse; bubble the skeleton out and let the caller decide.
69
+ return skeleton;
70
+ }
71
+ const prompt = `${REFINER_SYSTEM_PROMPT}\n\n` +
72
+ `INPUT SKILL.md:\n\`\`\`\n${skeleton}\n\`\`\`\n\n` +
73
+ `Output the refined SKILL.md only — no commentary, no code fences.`;
74
+ let refined;
75
+ try {
76
+ const result = await client.call({
77
+ purpose: 'skill_describe',
78
+ prompt,
79
+ maxTokens: opts.maxTokens ?? 1500,
80
+ timeoutMs: opts.timeoutMs ?? 20000,
81
+ });
82
+ refined = (result.content ?? '').trim();
83
+ }
84
+ catch {
85
+ return skeleton;
86
+ }
87
+ if (refined.length === 0)
88
+ return skeleton;
89
+ // Strip a wrapping ```...``` if the model returned one.
90
+ refined = refined.replace(/^```(?:markdown|md)?\s*\n/, '').replace(/\n```\s*$/, '');
91
+ // Validation 1 — attribution sweep. Refined output must not
92
+ // contain any forbidden token. The permanent sweep would catch
93
+ // this at ship time; we catch it earlier so a single noisy
94
+ // refinement doesn't pollute the candidate queue.
95
+ if (FORBIDDEN_TOKENS_RE.test(refined))
96
+ return skeleton;
97
+ // Validation 2 — round-trip through parseSkillContent and verify
98
+ // the canonical fields match the skeleton.
99
+ let refinedParsed;
100
+ try {
101
+ refinedParsed = (0, skillSpec_1.parseSkillContent)(refined);
102
+ }
103
+ catch {
104
+ return skeleton;
105
+ }
106
+ if (refinedParsed.frontmatter.name !== skeletonParsed.frontmatter.name ||
107
+ refinedParsed.frontmatter.version !== skeletonParsed.frontmatter.version) {
108
+ return skeleton;
109
+ }
110
+ return refined;
111
+ }
@@ -0,0 +1,139 @@
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/skillMining/proposalBuilder.ts — Phase v4.1-skill-mining
10
+ *
11
+ * Pure-function skeleton SKILL.md generator from a successful tool-
12
+ * call trace. Used by skillMiner BEFORE optional LLM refinement —
13
+ * the skeleton must already be a valid v4 skill (parseSkillContent
14
+ * round-trips), so that mining works offline when the auxiliary
15
+ * client is unavailable.
16
+ *
17
+ * Invariants:
18
+ * - emits required fields `name`, `description`, `version`
19
+ * - emits `metadata.aiden` with the mining provenance fields
20
+ * - body is numbered tool-call steps in markdown
21
+ * - never writes hermes/nous/copyright attribution strings — the
22
+ * permanent attribution sweep validates this
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.deriveName = deriveName;
26
+ exports.deriveDescription = deriveDescription;
27
+ exports.draft = draft;
28
+ const STOP_WORDS = new Set([
29
+ 'the', 'a', 'an', 'to', 'for', 'of', 'on', 'at', 'in', 'by', 'and', 'or', 'but', 'with',
30
+ 'from', 'please', 'can', 'you', 'will', 'do', 'does', 'my', 'our', 'this', 'that', 'these',
31
+ 'is', 'are', 'be', 'was', 'were', 'it', 'its', 'as', 'if', 'then', 'else', 'some', 'any',
32
+ 'me', 'i', 'your', 'their', 'his', 'her', 'him',
33
+ ]);
34
+ /**
35
+ * Derive a kebab-case skill-name from the first user message.
36
+ * Conservative — keeps only [a-z0-9-], collapses runs, max 40 chars.
37
+ * Falls back to `learned-skill-<short-fingerprint>` if extraction
38
+ * yields nothing usable.
39
+ */
40
+ function deriveName(firstUserPrompt, fingerprint) {
41
+ const lowered = firstUserPrompt.toLowerCase();
42
+ // Pull out non-stopword tokens, max 5 of them.
43
+ const tokens = lowered
44
+ .split(/[^a-z0-9]+/)
45
+ .filter((t) => t.length >= 3 && !STOP_WORDS.has(t))
46
+ .slice(0, 5);
47
+ let stem = tokens.join('-');
48
+ // Strict alphanum-and-dash, collapse multiple dashes, trim leading/trailing.
49
+ stem = stem.replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
50
+ if (stem.length === 0) {
51
+ return `learned-skill-${fingerprint.slice(0, 8)}`;
52
+ }
53
+ return stem.slice(0, 40);
54
+ }
55
+ /**
56
+ * Build the description line — single sentence summarising what the
57
+ * trace did. Keep it under 200 chars (the skill loader caps the
58
+ * displayed description in /skills list).
59
+ */
60
+ function deriveDescription(trace) {
61
+ if (trace.length === 0)
62
+ return 'Learned workflow.';
63
+ const tools = trace.map((e) => e.name).filter(Boolean);
64
+ const distinct = Array.from(new Set(tools));
65
+ if (distinct.length === 1) {
66
+ return `Learned workflow: ${distinct[0]} (${tools.length}x).`;
67
+ }
68
+ const head = distinct.slice(0, 4).join(' → ');
69
+ const more = distinct.length > 4 ? ` (+${distinct.length - 4} more)` : '';
70
+ return `Learned workflow: ${head}${more}.`;
71
+ }
72
+ /**
73
+ * Render the body as numbered tool-call steps. Args are JSON-stringified
74
+ * and clipped at 120 chars per step so a chatty trace doesn't bloat
75
+ * SKILL.md to multi-MB.
76
+ */
77
+ function renderBody(trace) {
78
+ if (trace.length === 0) {
79
+ return '## Steps\n\n_(empty trace)_\n';
80
+ }
81
+ const out = ['## Steps', ''];
82
+ trace.forEach((entry, i) => {
83
+ const idx = i + 1;
84
+ const argSummary = (() => {
85
+ if (entry.args == null || typeof entry.args !== 'object')
86
+ return '';
87
+ try {
88
+ const json = JSON.stringify(entry.args);
89
+ if (json.length <= 120)
90
+ return ` \\\`${json}\\\``;
91
+ return ` \\\`${json.slice(0, 117)}…\\\``;
92
+ }
93
+ catch {
94
+ return '';
95
+ }
96
+ })();
97
+ out.push(`${idx}. **${entry.name}**${argSummary}`);
98
+ });
99
+ out.push('');
100
+ out.push('## Notes', '');
101
+ out.push('Mined from a successful turn — review the steps above before relying on it.');
102
+ out.push('');
103
+ return out.join('\n');
104
+ }
105
+ /**
106
+ * Build a full SKILL.md (frontmatter + body) ready for parseSkillContent.
107
+ * The output is deterministic for a given (trace, context) input —
108
+ * smokes assert this for stable round-trip behaviour.
109
+ */
110
+ function draft(trace, ctx) {
111
+ const name = ctx.nameOverride?.trim() || deriveName(ctx.firstUserPrompt, ctx.traceFingerprint);
112
+ const description = deriveDescription(trace);
113
+ const version = '0.1.0';
114
+ const createdAt = new Date().toISOString();
115
+ // YAML frontmatter — keep field order stable so smokes can pin
116
+ // shape without depending on YAML library output ordering.
117
+ const frontmatter = [
118
+ '---',
119
+ `name: ${name}`,
120
+ `description: ${JSON.stringify(description)}`,
121
+ `version: ${version}`,
122
+ 'category: learned',
123
+ 'metadata:',
124
+ ' aiden:',
125
+ ' learned: true',
126
+ ` sourceSessionId: ${JSON.stringify(ctx.sourceSessionId)}`,
127
+ ` sourceTurnIdx: ${ctx.sourceTurnIdx}`,
128
+ ` createdAt: ${JSON.stringify(createdAt)}`,
129
+ ` traceFingerprint: ${JSON.stringify(ctx.traceFingerprint)}`,
130
+ ` candidateConfidence: ${ctx.candidateConfidence.toFixed(3)}`,
131
+ '---',
132
+ '',
133
+ `# ${name}`,
134
+ '',
135
+ description,
136
+ '',
137
+ ].join('\n');
138
+ return frontmatter + renderBody(trace);
139
+ }
@@ -0,0 +1,191 @@
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/skillMining/skillMiner.ts — Phase v4.1-skill-mining
10
+ *
11
+ * Orchestrator for the skill-mining post-turn observation. Sits next
12
+ * to `moat/skillTeacher.ts:observeTurn` in the agent loop hook
13
+ * (`core/v4/aidenAgent.ts:440-468`); the two are complementary.
14
+ * SkillTeacher proposes inline (immediate accept/reject); SkillMiner
15
+ * stages a candidate for `/skills review` so the user can audit
16
+ * before any disk-mutation lands in the live skills directory.
17
+ *
18
+ * Programmatic gates (in order — first failure short-circuits):
19
+ * 1. trace length < 3 → skip
20
+ * 2. any tool errored → skip
21
+ * 3. finishReason !== 'stop' → skip
22
+ * 4. session candidate count >= SESSION_SKILL_LIMIT → skip
23
+ * 5. user opt-out phrase in conversation → skip (reuse OPT_OUT_RE)
24
+ * 6. fingerprint matches pending candidate → skip (dedup)
25
+ * 7. fingerprint matches rejected list → skip (dedup)
26
+ *
27
+ * Pass-through pipeline:
28
+ * - compute confidence score from programmatic features
29
+ * - draft skeleton via proposalBuilder
30
+ * - refine via extractorPrompt (best-effort; falls back to
31
+ * skeleton)
32
+ * - validate via parseSkillContent round-trip
33
+ * - append to candidateStore
34
+ * - return ObservationResult so chatSession can notify
35
+ *
36
+ * MCP serve mode: caller (aidenAgent) MUST gate on
37
+ * !isMcpServeMode() before invoking observeTurn — the mining
38
+ * subsystem itself doesn't write to stdout but a candidate
39
+ * notification would, and serve mode owns stdout for JSON-RPC.
40
+ */
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.SkillMiner = exports.SESSION_SKILL_LIMIT = void 0;
43
+ exports.computeConfidence = computeConfidence;
44
+ const node_crypto_1 = require("node:crypto");
45
+ const skillSpec_1 = require("../skillSpec");
46
+ const candidateStore_1 = require("./candidateStore");
47
+ const traceFingerprint_1 = require("./traceFingerprint");
48
+ const proposalBuilder_1 = require("./proposalBuilder");
49
+ const extractorPrompt_1 = require("./extractorPrompt");
50
+ /** Per-session candidate cap — port from v3's `SESSION_SKILL_LIMIT`. */
51
+ exports.SESSION_SKILL_LIMIT = 2;
52
+ /** Minimum trace length to consider mining at all. */
53
+ const MIN_TRACE_LENGTH = 3;
54
+ /**
55
+ * Opt-out phrases that suppress mining for the current turn. Match
56
+ * the SkillTeacher pattern at `moat/skillTeacher.ts:110` so a user
57
+ * who silences SkillTeacher also silences mining.
58
+ */
59
+ const OPT_OUT_RE = /\b(stop|don['']?t|no|never)\s+(suggest|propose|create|save|learn|remember)\w*\b/i;
60
+ /**
61
+ * Compute a 0..1 confidence score from programmatic trace features.
62
+ * Used to sort `/skills review` so the most promising candidates
63
+ * surface first.
64
+ *
65
+ * Components:
66
+ * - lengthScore : trace length sweet spot is 5..15 (peaks at 10)
67
+ * - errorRate : already gated to 0 (no errors in trace) but the
68
+ * feature is computed for forward-compat with
69
+ * future "soft" mining that admits some errors
70
+ * - distinctSet : more distinct tools = more reusable workflow
71
+ * - distinctTools: simple toolset diversity
72
+ */
73
+ function computeConfidence(trace) {
74
+ if (trace.length === 0)
75
+ return 0;
76
+ // Length score — peaks at 10, falls off at extremes.
77
+ const len = trace.length;
78
+ const lengthScore = len < 3 ? 0 :
79
+ len <= 10 ? len / 10 :
80
+ len <= 15 ? 1 - (len - 10) * 0.04 :
81
+ Math.max(0.4, 1 - (len - 10) * 0.05);
82
+ // Error rate (0 = best, 1 = worst).
83
+ const errors = trace.filter((e) => e.error != null).length;
84
+ const errorRate = errors / trace.length;
85
+ // Distinct tool diversity.
86
+ const distinctTools = new Set(trace.map((e) => e.name)).size;
87
+ const diversityScore = Math.min(1, distinctTools / 4);
88
+ // Distinct toolsets diversity (some traces tag entries).
89
+ const distinctSets = new Set(trace.map((e) => e.toolset ?? '').filter(Boolean)).size;
90
+ const setBonus = Math.min(0.2, distinctSets * 0.1);
91
+ // Weighted average — length and diversity dominate, error penalty
92
+ // proportional to rate.
93
+ const raw = 0.55 * lengthScore + 0.35 * diversityScore + setBonus - errorRate;
94
+ return Math.max(0, Math.min(1, Number(raw.toFixed(3))));
95
+ }
96
+ /** Single-trace orchestrator. Stateless except for the per-session counter. */
97
+ class SkillMiner {
98
+ constructor(opts = {}) {
99
+ this.perSessionCount = new Map();
100
+ this.store = opts.store ?? new candidateStore_1.CandidateStore();
101
+ this.auxiliaryClient = opts.auxiliaryClient;
102
+ this.sessionCap = opts.sessionCap ?? exports.SESSION_SKILL_LIMIT;
103
+ this.skeletonOnly = opts.skeletonOnly ?? false;
104
+ }
105
+ /** Test/reset hook. */
106
+ _resetForTests() {
107
+ this.perSessionCount.clear();
108
+ }
109
+ /** Returns the count of pending candidates already attributed to a session. */
110
+ countForSession(sessionId) {
111
+ return this.perSessionCount.get(sessionId) ?? 0;
112
+ }
113
+ async observeTurn(obs) {
114
+ // Gate 1 — short trace.
115
+ if (!obs.trace || obs.trace.length < MIN_TRACE_LENGTH) {
116
+ return { status: 'short-trace' };
117
+ }
118
+ // Gate 2 — any tool errored.
119
+ if (obs.trace.some((e) => e.error != null)) {
120
+ return { status: 'tool-error' };
121
+ }
122
+ // Gate 3 — turn was aborted.
123
+ if (obs.finishReason !== 'stop') {
124
+ return { status: 'abort' };
125
+ }
126
+ // Gate 4 — session cap.
127
+ if (this.countForSession(obs.sessionId) >= this.sessionCap) {
128
+ return { status: 'session-cap' };
129
+ }
130
+ // Gate 5 — user opt-out anywhere in this turn's conversation.
131
+ for (const msg of obs.history) {
132
+ if (msg.role === 'user' && typeof msg.content === 'string' && OPT_OUT_RE.test(msg.content)) {
133
+ return { status: 'opt-out' };
134
+ }
135
+ }
136
+ // Fingerprint + dedup.
137
+ const fpEntries = obs.trace.map((e) => ({ name: e.name, args: e.args }));
138
+ const fingerprint = (0, traceFingerprint_1.traceFingerprint)(fpEntries);
139
+ const pending = await this.store.list();
140
+ if (pending.some((c) => c.fingerprint === fingerprint)) {
141
+ return { status: 'dedup-pending' };
142
+ }
143
+ const rejected = await this.store.loadRejected();
144
+ if (rejected.has(fingerprint)) {
145
+ return { status: 'dedup-rejected' };
146
+ }
147
+ // Compute confidence + first user prompt for skeleton seeding.
148
+ const confidence = computeConfidence(obs.trace);
149
+ const firstUserPrompt = (() => {
150
+ for (const m of obs.history) {
151
+ if (m.role === 'user' && typeof m.content === 'string' && m.content.trim().length > 0) {
152
+ return m.content.trim();
153
+ }
154
+ }
155
+ return '';
156
+ })();
157
+ const ctx = {
158
+ firstUserPrompt,
159
+ sourceSessionId: obs.sessionId,
160
+ sourceTurnIdx: obs.sourceTurnIdx,
161
+ traceFingerprint: fingerprint,
162
+ candidateConfidence: confidence,
163
+ };
164
+ let skill = (0, proposalBuilder_1.draft)(obs.trace, ctx);
165
+ if (!this.skeletonOnly) {
166
+ skill = await (0, extractorPrompt_1.refine)(skill, { client: this.auxiliaryClient });
167
+ }
168
+ // Final validation — must round-trip through parseSkillContent
169
+ // (the canonical loader parser). If it doesn't, drop the
170
+ // candidate rather than poison the queue.
171
+ try {
172
+ (0, skillSpec_1.parseSkillContent)(skill);
173
+ }
174
+ catch {
175
+ return { status: 'invalid-skill', confidence };
176
+ }
177
+ const candidate = {
178
+ id: (0, node_crypto_1.randomUUID)(),
179
+ fingerprint,
180
+ sourceSessionId: obs.sessionId,
181
+ sourceTurnIdx: obs.sourceTurnIdx,
182
+ createdAt: new Date().toISOString(),
183
+ candidateConfidence: confidence,
184
+ skillContent: skill,
185
+ };
186
+ await this.store.append(candidate);
187
+ this.perSessionCount.set(obs.sessionId, this.countForSession(obs.sessionId) + 1);
188
+ return { status: 'queued', candidate, confidence };
189
+ }
190
+ }
191
+ exports.SkillMiner = SkillMiner;