mixdog 0.9.151 → 0.9.152
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 +110 -68
- package/package.json +3 -2
- package/scripts/tool-stress.mjs +0 -2
- package/src/defaults/skills/setup/SKILL.md +18 -27
- package/src/headless-exec.mjs +3 -10
- package/src/headless-exec.test.mjs +2 -7
- package/src/runtime/agent/orchestrator/config.mjs +6 -66
- package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
- package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
- package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
- package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +10 -2
- package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
- package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -2
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +4 -7
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +235 -0
- package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
- package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
- package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +14 -4
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +24 -1
- package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +161 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +14 -4
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +31 -18
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +6 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -3
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +556 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +457 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +105 -186
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +23 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
- package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
- package/src/runtime/browser-bridge/client.mjs +103 -0
- package/src/runtime/browser-bridge/tool-defs.mjs +42 -0
- package/src/runtime/computer-bridge/client.mjs +101 -0
- package/src/runtime/computer-bridge/tool-defs.mjs +53 -0
- package/src/runtime/media/store.mjs +18 -8
- package/src/runtime/media/store.test.mjs +14 -0
- package/src/runtime/memory/index.mjs +1 -1
- package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
- package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
- package/src/runtime/shared/child-spawn-gate.mjs +17 -0
- package/src/runtime/shared/child-spawn-remote.mjs +69 -7
- package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
- package/src/runtime/shared/pristine-execution-contract.json +2 -1
- package/src/runtime/shared/provider-api-key.mjs +1 -0
- package/src/runtime/shared/session-runtime-health.mjs +91 -0
- package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
- package/src/runtime/shared/skill-document.mjs +86 -0
- package/src/runtime/shared/skill-document.test.mjs +81 -0
- package/src/runtime/shared/tool-surface.mjs +18 -0
- package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
- package/src/runtime/shared/turn-snapshot.mjs +32 -9
- package/src/runtime/shared/user-cwd.mjs +33 -14
- package/src/runtime/shared/user-cwd.test.mjs +43 -0
- package/src/session-runtime/cwd-plugins.mjs +26 -26
- package/src/session-runtime/cwd-plugins.test.mjs +75 -0
- package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
- package/src/session-runtime/global-extensions.mjs +26 -0
- package/src/session-runtime/global-extensions.test.mjs +28 -0
- package/src/session-runtime/goal-runtime.mjs +721 -0
- package/src/session-runtime/goal-runtime.test.mjs +108 -0
- package/src/session-runtime/lifecycle-api.mjs +18 -4
- package/src/session-runtime/lifecycle-api.test.mjs +21 -0
- package/src/session-runtime/mcp-glue.mjs +49 -47
- package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
- package/src/session-runtime/plugin-mcp.mjs +96 -22
- package/src/session-runtime/prewarm.mjs +1 -10
- package/src/session-runtime/resource-api-global.test.mjs +69 -0
- package/src/session-runtime/resource-api.mjs +173 -58
- package/src/session-runtime/runtime-core.mjs +148 -20
- package/src/session-runtime/runtime-tunables.mjs +6 -8
- package/src/session-runtime/session-turn-api.mjs +22 -11
- package/src/session-runtime/skills-api.mjs +79 -25
- package/src/session-runtime/skills-api.test.mjs +99 -0
- package/src/session-runtime/tool-catalog-schema.mjs +0 -5
- package/src/session-runtime/tool-catalog.mjs +4 -13
- package/src/session-runtime/tool-policy-surface.test.mjs +33 -11
- package/src/session-runtime/tool-surface.mjs +12 -0
- package/src/standalone/agent-dispatch-broker.mjs +5 -9
- package/src/standalone/agent-tool/notify.mjs +6 -8
- package/src/standalone/agent-tool/notify.test.mjs +37 -0
- package/src/standalone/daemon.mjs +31 -15
- package/src/standalone/hook-bus/config.mjs +1 -0
- package/src/standalone/plugin-admin.mjs +17 -0
- package/src/standalone/plugin-admin.test.mjs +7 -0
- package/src/standalone/provider-admin.mjs +1 -0
- package/src/standalone/session-protocol.mjs +5 -0
- package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
- package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
- package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
- package/src/standalone/session-runtime-host-factory.mjs +17 -0
- package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
- package/src/standalone/session-runtime-host-health.test.mjs +5 -0
- package/src/standalone/session-runtime-host.mjs +696 -58
- package/src/standalone/session-runtime-inline-host.mjs +351 -0
- package/src/standalone/session-runtime-inline-host.test.mjs +83 -0
- package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
- package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
- package/src/standalone/session-runtime-shard-host.test.mjs +569 -0
- package/src/standalone/session-runtime-shard-router.mjs +149 -0
- package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
- package/src/standalone/session-runtime-worker.mjs +476 -18
- package/src/standalone/session-service.mjs +81 -6
- package/src/tui/app/app-view.jsx +1 -0
- package/src/tui/app/core-memory-picker.mjs +2 -0
- package/src/tui/app/extension-pickers.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +3 -3
- package/src/tui/app/model-options.mjs +2 -0
- package/src/tui/app/model-picker.mjs +14 -4
- package/src/tui/app/panel-handoff.test.mjs +311 -0
- package/src/tui/app/project-picker.mjs +1 -0
- package/src/tui/app/route-pickers.mjs +21 -6
- package/src/tui/app/settings-picker.mjs +26 -3
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +73 -22
- package/src/tui/app/theme-effort-pickers.mjs +7 -2
- package/src/tui/components/Picker.jsx +10 -1
- package/src/tui/dist/index.mjs +163 -44
- package/src/tui/session/context-state.mjs +1 -0
- package/src/tui/session/goal-continuation.mjs +110 -0
- package/src/tui/session/goal-continuation.test.mjs +84 -0
- package/src/tui/session/queue-helpers.mjs +5 -2
- package/src/tui/session/session-action-surface.test.mjs +108 -0
- package/src/tui/session/session-api-ext.mjs +39 -8
- package/src/tui/session/session-api.mjs +58 -0
- package/src/tui/session/session-flow.mjs +6 -0
- package/src/tui/session/turn.mjs +1 -0
- package/src/tui/session-local.mjs +15 -1
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
|
@@ -264,7 +264,7 @@ export function createTranscriptIngest({
|
|
|
264
264
|
_transcriptOffsets = new Map()
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
function initTranscriptWatcher() {
|
|
267
|
+
async function initTranscriptWatcher() {
|
|
268
268
|
const root = projectsRoot()
|
|
269
269
|
const SAFETY_POLL_MS = 5 * 60_000
|
|
270
270
|
const DEBOUNCE_MS = 500
|
|
@@ -273,7 +273,6 @@ export function createTranscriptIngest({
|
|
|
273
273
|
const watchers = []
|
|
274
274
|
const intervals = []
|
|
275
275
|
const polledFiles = new Set()
|
|
276
|
-
let safetySweepTimeout = null
|
|
277
276
|
|
|
278
277
|
function isWatchable(relOrBase) {
|
|
279
278
|
const base = path.basename(relOrBase)
|
|
@@ -345,14 +344,12 @@ export function createTranscriptIngest({
|
|
|
345
344
|
async function safetySweep() {
|
|
346
345
|
try {
|
|
347
346
|
const active = await discoverActiveTranscripts()
|
|
348
|
-
|
|
347
|
+
await Promise.all(active.map(({ path: fp }) => ingestOne(fp)))
|
|
349
348
|
} catch (e) {
|
|
350
349
|
log(`[transcript-watch] safety sweep error: ${e.message}\n`)
|
|
351
350
|
}
|
|
352
351
|
}
|
|
353
352
|
|
|
354
|
-
safetySweepTimeout = setTimeout(safetySweep, 3_000)
|
|
355
|
-
|
|
356
353
|
// fs.watch({recursive}) is only reliable on win32.
|
|
357
354
|
// darwin: recursive option unreliable — use flat watch per-entry (glob dirs at start).
|
|
358
355
|
// linux/WSL: recursive not supported — use fs.watchFile polling per file found via
|
|
@@ -429,9 +426,14 @@ export function createTranscriptIngest({
|
|
|
429
426
|
intervals.push(setInterval(_patchedSweep, SAFETY_POLL_MS))
|
|
430
427
|
}
|
|
431
428
|
|
|
429
|
+
// Runtime readiness includes the first active-transcript sweep. Without
|
|
430
|
+
// this barrier a lazy-started memory process can answer recall before the
|
|
431
|
+
// current session's persisted rows exist, causing automatic compaction to
|
|
432
|
+
// fail during the former three-second startup window.
|
|
433
|
+
await safetySweep()
|
|
434
|
+
|
|
432
435
|
return {
|
|
433
436
|
stop() {
|
|
434
|
-
if (safetySweepTimeout) { clearTimeout(safetySweepTimeout); safetySweepTimeout = null }
|
|
435
437
|
for (const t of pendingByFile.values()) { try { clearTimeout(t) } catch {} }
|
|
436
438
|
pendingByFile.clear()
|
|
437
439
|
for (const i of intervals) { try { clearInterval(i) } catch {} }
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
|
|
7
|
+
import { createTranscriptIngest } from './transcript-ingest.mjs'
|
|
8
|
+
|
|
9
|
+
test('transcript watcher initialization waits for the first active-session sweep', async () => {
|
|
10
|
+
const root = await mkdtemp(path.join(tmpdir(), 'mixdog-transcript-watch-'))
|
|
11
|
+
const projectDir = path.join(root, 'project')
|
|
12
|
+
const sessionId = 'sess_readiness_regression'
|
|
13
|
+
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`)
|
|
14
|
+
const inserts = []
|
|
15
|
+
let offsets = '{}'
|
|
16
|
+
let watcher = null
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
await mkdir(projectDir, { recursive: true })
|
|
20
|
+
await writeFile(transcriptPath, `${JSON.stringify({
|
|
21
|
+
type: 'user',
|
|
22
|
+
timestamp: new Date().toISOString(),
|
|
23
|
+
cwd: projectDir,
|
|
24
|
+
message: { content: 'persisted before memory readiness' },
|
|
25
|
+
})}\n`)
|
|
26
|
+
|
|
27
|
+
const ingest = createTranscriptIngest({
|
|
28
|
+
getDb: () => ({
|
|
29
|
+
async query(_sql, values) {
|
|
30
|
+
inserts.push(values)
|
|
31
|
+
return { rowCount: 1 }
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
loadMeta: async () => offsets,
|
|
35
|
+
persistMeta: async (value) => { offsets = value },
|
|
36
|
+
projectsRoot: () => root,
|
|
37
|
+
resolveProjectId: () => 'project-readiness',
|
|
38
|
+
firstTextContent: (value) => String(value ?? ''),
|
|
39
|
+
cleanMemoryText: (value) => String(value ?? '').trim(),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
await ingest.loadTranscriptOffsets()
|
|
43
|
+
watcher = await ingest.initTranscriptWatcher()
|
|
44
|
+
|
|
45
|
+
assert.equal(inserts.length, 1)
|
|
46
|
+
assert.equal(inserts[0][2], 'persisted before memory readiness')
|
|
47
|
+
assert.equal(inserts[0][4], sessionId)
|
|
48
|
+
} finally {
|
|
49
|
+
watcher?.stop()
|
|
50
|
+
await rm(root, { recursive: true, force: true })
|
|
51
|
+
}
|
|
52
|
+
})
|
|
@@ -128,6 +128,19 @@ const SLOW_WAIT_MS = Math.max(
|
|
|
128
128
|
const SLOW_WARN_THROTTLE_MS = 30000;
|
|
129
129
|
|
|
130
130
|
let _lastSlowWarnAt = 0;
|
|
131
|
+
let _lastFallbackWarnAt = 0;
|
|
132
|
+
|
|
133
|
+
function _warnLeaseFallback(error, laneName) {
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
if (now - _lastFallbackWarnAt < SLOW_WARN_THROTTLE_MS) return;
|
|
136
|
+
_lastFallbackWarnAt = now;
|
|
137
|
+
try {
|
|
138
|
+
process.stderr.write(
|
|
139
|
+
`[child-spawn-gate] lane=${laneName} machine spawn budget unavailable`
|
|
140
|
+
+ ` (${error?.message || error}); using this process's bounded local lane\n`,
|
|
141
|
+
);
|
|
142
|
+
} catch { /* ignore */ }
|
|
143
|
+
}
|
|
131
144
|
|
|
132
145
|
function _maybeWarnSlow(waitedMs, laneName, lane) {
|
|
133
146
|
if (waitedMs < SLOW_WAIT_MS) return;
|
|
@@ -175,6 +188,10 @@ export function acquire(signal = null, laneName = 'search', options = {}) {
|
|
|
175
188
|
waitTimeoutMs,
|
|
176
189
|
}).catch((error) => {
|
|
177
190
|
if (error?.code !== 'ELEASEFALLBACK') throw error;
|
|
191
|
+
// Only a genuinely dead pool channel reaches here (the client re-arms
|
|
192
|
+
// while the link is alive). Say so loudly: from this point the cap is
|
|
193
|
+
// per-process, so N shards can hold N× the machine-wide budget.
|
|
194
|
+
_warnLeaseFallback(error, normalizedLaneName);
|
|
178
195
|
return _acquireLocal(signal, normalizedLaneName, lane, ownerKey, waitTimeoutMs);
|
|
179
196
|
});
|
|
180
197
|
}
|
|
@@ -16,7 +16,32 @@ import { safeIpcSend } from './safe-ipc-send.mjs';
|
|
|
16
16
|
const _pending = new Map(); // leaseId -> { grant, fail }
|
|
17
17
|
let _sequence = 0;
|
|
18
18
|
let _listening = false;
|
|
19
|
-
|
|
19
|
+
// "Lost" means the parent IPC link is actually gone — never merely slow. A
|
|
20
|
+
// single timed-out or failed lease must NOT latch this process onto the
|
|
21
|
+
// process-local lane: with several runtime shards that silently multiplies the
|
|
22
|
+
// machine-wide child-spawn cap, which is exactly the Windows spawn storm this
|
|
23
|
+
// gate exists to prevent.
|
|
24
|
+
let _channelLost = false;
|
|
25
|
+
// Bounded patience for a busy/recovering daemon. Each expiry re-arms while the
|
|
26
|
+
// link is alive, so IPC latency keeps shards on the machine-wide budget.
|
|
27
|
+
const LEASE_WAIT_EXTENSIONS = Math.max(
|
|
28
|
+
0,
|
|
29
|
+
Math.floor(Number(process.env.MIXDOG_SPAWN_LEASE_WAIT_EXTENSIONS) || 3),
|
|
30
|
+
);
|
|
31
|
+
const LEASE_WARN_THROTTLE_MS = 30_000;
|
|
32
|
+
let _lastLeaseWarnAt = 0;
|
|
33
|
+
|
|
34
|
+
function _warnSlowLease(budgetMs, extensionsLeft) {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
if (now - _lastLeaseWarnAt < LEASE_WARN_THROTTLE_MS) return;
|
|
37
|
+
_lastLeaseWarnAt = now;
|
|
38
|
+
try {
|
|
39
|
+
process.stderr.write(
|
|
40
|
+
`[child-spawn-remote] machine spawn lease pending >${budgetMs}ms; staying on the daemon budget`
|
|
41
|
+
+ ` (${extensionsLeft} extension(s) left)\n`,
|
|
42
|
+
);
|
|
43
|
+
} catch { /* diagnostics only */ }
|
|
44
|
+
}
|
|
20
45
|
|
|
21
46
|
function _fallbackError(reason) {
|
|
22
47
|
const error = new Error(`machine spawn budget unavailable (${reason}); using the local lane`);
|
|
@@ -24,6 +49,12 @@ function _fallbackError(reason) {
|
|
|
24
49
|
return error;
|
|
25
50
|
}
|
|
26
51
|
|
|
52
|
+
function _leaseTimeoutError(reason) {
|
|
53
|
+
const error = new Error(`machine spawn budget did not answer (${reason}); spawn was not started`);
|
|
54
|
+
error.code = 'ESPAWNLEASETIMEOUT';
|
|
55
|
+
return error;
|
|
56
|
+
}
|
|
57
|
+
|
|
27
58
|
function _ensureListener() {
|
|
28
59
|
if (_listening) return;
|
|
29
60
|
_listening = true;
|
|
@@ -42,7 +73,7 @@ function _ensureListener() {
|
|
|
42
73
|
pending.fail(error);
|
|
43
74
|
});
|
|
44
75
|
process.on('disconnect', () => {
|
|
45
|
-
|
|
76
|
+
_channelLost = true;
|
|
46
77
|
for (const [leaseId, pending] of [..._pending]) {
|
|
47
78
|
_pending.delete(leaseId);
|
|
48
79
|
pending.fail(_fallbackError('pool channel disconnected'));
|
|
@@ -50,9 +81,20 @@ function _ensureListener() {
|
|
|
50
81
|
});
|
|
51
82
|
}
|
|
52
83
|
|
|
84
|
+
/**
|
|
85
|
+
* PID-accurate shard detection. MIXDOG_SESSION_RUNTIME_WORKER is inherited by
|
|
86
|
+
* every grandchild (tools, helpers, spawned nodes), so the plain flag cannot
|
|
87
|
+
* decide who owns a runtime IPC channel: only the process whose OWN pid matches
|
|
88
|
+
* the pid-scoped marker is a runtime shard. A grandchild therefore uses its own
|
|
89
|
+
* bounded local lane, and a real shard is never misread into it.
|
|
90
|
+
*/
|
|
91
|
+
export function isSessionRuntimeWorkerProcess(env = process.env, pid = process.pid) {
|
|
92
|
+
return String(env.MIXDOG_SESSION_RUNTIME_WORKER_PID || '') === String(pid);
|
|
93
|
+
}
|
|
94
|
+
|
|
53
95
|
export function remoteSpawnLeasesEnabled(env = process.env) {
|
|
54
|
-
return !
|
|
55
|
-
&& env
|
|
96
|
+
return !_channelLost
|
|
97
|
+
&& isSessionRuntimeWorkerProcess(env)
|
|
56
98
|
&& env.MIXDOG_DISABLE_MACHINE_SPAWN_BUDGET !== '1'
|
|
57
99
|
&& typeof process.send === 'function'
|
|
58
100
|
&& process.connected === true;
|
|
@@ -91,7 +133,6 @@ export function acquireRemoteSpawnLease({ lane, ownerKey, signal, waitTimeoutMs
|
|
|
91
133
|
if (settled) return;
|
|
92
134
|
settled = true;
|
|
93
135
|
cleanup();
|
|
94
|
-
if (error?.code === 'ELEASEFALLBACK') _broken = true;
|
|
95
136
|
reject(error);
|
|
96
137
|
};
|
|
97
138
|
const grant = () => {
|
|
@@ -124,8 +165,29 @@ export function acquireRemoteSpawnLease({ lane, ownerKey, signal, waitTimeoutMs
|
|
|
124
165
|
// The pool enforces the real waitTimeoutMs; this guard only covers a
|
|
125
166
|
// parent that never answers (bug/kill) so a spawn can never hang forever.
|
|
126
167
|
const budget = Math.max(1_000, Math.floor(Number(waitTimeoutMs) || 0)) + 5_000;
|
|
127
|
-
|
|
128
|
-
|
|
168
|
+
let extensionsLeft = LEASE_WAIT_EXTENSIONS;
|
|
169
|
+
const armSafety = () => {
|
|
170
|
+
safety = setTimeout(() => {
|
|
171
|
+
// A busy or recovering daemon is latency, not a broken budget
|
|
172
|
+
// authority: while the link is alive, keep waiting instead of dropping
|
|
173
|
+
// to a per-process lane that multiplies the machine-wide cap.
|
|
174
|
+
if (extensionsLeft > 0 && !_channelLost && process.connected === true) {
|
|
175
|
+
extensionsLeft -= 1;
|
|
176
|
+
_warnSlowLease(budget, extensionsLeft);
|
|
177
|
+
armSafety();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// A still-connected authority may be wedged, but bypassing it would
|
|
181
|
+
// multiply the machine cap by the shard count. Fail this spawn instead;
|
|
182
|
+
// only an actually lost IPC channel is allowed to use the bounded
|
|
183
|
+
// process-local fallback lane.
|
|
184
|
+
fail(process.connected === true && !_channelLost
|
|
185
|
+
? _leaseTimeoutError(`no lease response in ${budget}ms`)
|
|
186
|
+
: _fallbackError(`no lease response in ${budget}ms`));
|
|
187
|
+
}, budget);
|
|
188
|
+
safety.unref?.();
|
|
189
|
+
};
|
|
190
|
+
armSafety();
|
|
129
191
|
const sent = safeIpcSend(process, {
|
|
130
192
|
type: 'spawn-lease',
|
|
131
193
|
leaseId,
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Machine-wide child-spawn budget: every runtime shard must stay on the
|
|
2
|
+
// daemon-owned gate. Two ways that silently breaks (and multiplies the cap by
|
|
3
|
+
// the shard count) are covered here: an inherited env flag activating the
|
|
4
|
+
// process-local lane in a grandchild, and one failed lease latching a shard
|
|
5
|
+
// off the remote budget for the rest of its life.
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { fork } from 'node:child_process';
|
|
8
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import test from 'node:test';
|
|
13
|
+
|
|
14
|
+
import { isSessionRuntimeWorkerProcess } from './child-spawn-remote.mjs';
|
|
15
|
+
|
|
16
|
+
const REMOTE_URL = new URL('./child-spawn-remote.mjs', import.meta.url).href;
|
|
17
|
+
const GATE_URL = new URL('./child-spawn-gate.mjs', import.meta.url).href;
|
|
18
|
+
|
|
19
|
+
const CHILD_SOURCE = `
|
|
20
|
+
const inherited = process.env.STUB_PID_MODE === 'inherited';
|
|
21
|
+
process.env.MIXDOG_SESSION_RUNTIME_WORKER = '1';
|
|
22
|
+
// A grandchild inherits BOTH variables; only its own pid differs.
|
|
23
|
+
process.env.MIXDOG_SESSION_RUNTIME_WORKER_PID = inherited
|
|
24
|
+
? String(process.pid + 1)
|
|
25
|
+
: String(process.pid);
|
|
26
|
+
const remote = await import(process.env.REMOTE_URL);
|
|
27
|
+
const gate = await import(process.env.GATE_URL);
|
|
28
|
+
const report = { enabled: remote.remoteSpawnLeasesEnabled(), mode: gate.snapshot().mode };
|
|
29
|
+
if (!inherited) {
|
|
30
|
+
try {
|
|
31
|
+
await remote.acquireRemoteSpawnLease({ lane: 'search', ownerKey: 'test', waitTimeoutMs: 30000 });
|
|
32
|
+
report.firstLease = 'granted';
|
|
33
|
+
} catch (error) {
|
|
34
|
+
report.firstLease = String(error?.code || error?.message || error);
|
|
35
|
+
}
|
|
36
|
+
report.enabledAfterFailure = remote.remoteSpawnLeasesEnabled();
|
|
37
|
+
report.modeAfterFailure = gate.snapshot().mode;
|
|
38
|
+
const release = await remote.acquireRemoteSpawnLease({ lane: 'search', ownerKey: 'test', waitTimeoutMs: 30000 });
|
|
39
|
+
report.secondLease = typeof release === 'function' ? 'granted' : 'unexpected';
|
|
40
|
+
release();
|
|
41
|
+
}
|
|
42
|
+
process.send({ type: 'report', report });
|
|
43
|
+
setTimeout(() => process.exit(0), 50).unref?.();
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
async function runChild(mode, { onLease = () => {} } = {}) {
|
|
47
|
+
const dir = await mkdtemp(join(tmpdir(), 'mixdog-spawn-remote-'));
|
|
48
|
+
const entry = join(dir, 'child.mjs');
|
|
49
|
+
await writeFile(entry, CHILD_SOURCE, 'utf8');
|
|
50
|
+
try {
|
|
51
|
+
return await new Promise((resolve, reject) => {
|
|
52
|
+
const child = fork(entry, [], {
|
|
53
|
+
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
|
54
|
+
env: {
|
|
55
|
+
...process.env,
|
|
56
|
+
STUB_PID_MODE: mode,
|
|
57
|
+
REMOTE_URL,
|
|
58
|
+
GATE_URL,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
try { child.kill(); } catch {}
|
|
63
|
+
reject(new Error('child spawn-lease probe timed out'));
|
|
64
|
+
}, 20_000);
|
|
65
|
+
let stderr = '';
|
|
66
|
+
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
|
67
|
+
child.on('message', (message) => {
|
|
68
|
+
if (message?.type === 'spawn-lease') {
|
|
69
|
+
onLease(child, message);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (message?.type !== 'report') return;
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
try { child.kill(); } catch {}
|
|
75
|
+
resolve({ ...message.report, stderr });
|
|
76
|
+
});
|
|
77
|
+
child.on('error', reject);
|
|
78
|
+
});
|
|
79
|
+
} finally {
|
|
80
|
+
await rm(dir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
test('only the pid-scoped runtime worker uses the machine-wide spawn budget', () => {
|
|
85
|
+
assert.equal(isSessionRuntimeWorkerProcess({ MIXDOG_SESSION_RUNTIME_WORKER_PID: '4242' }, 4242), true);
|
|
86
|
+
assert.equal(isSessionRuntimeWorkerProcess({ MIXDOG_SESSION_RUNTIME_WORKER_PID: '4242' }, 99), false);
|
|
87
|
+
// The inheritable flag alone can never claim the runtime IPC channel.
|
|
88
|
+
assert.equal(isSessionRuntimeWorkerProcess({ MIXDOG_SESSION_RUNTIME_WORKER: '1' }, 99), false);
|
|
89
|
+
assert.equal(isSessionRuntimeWorkerProcess({}, 99), false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a grandchild that inherited the worker flag stays on its own local lane', async () => {
|
|
93
|
+
const report = await runChild('inherited');
|
|
94
|
+
assert.equal(report.enabled, false);
|
|
95
|
+
assert.equal(report.mode, 'local');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a failed lease never latches a shard off the machine-wide budget', async () => {
|
|
99
|
+
let leases = 0;
|
|
100
|
+
const report = await runChild('shard', {
|
|
101
|
+
onLease: (child, message) => {
|
|
102
|
+
leases += 1;
|
|
103
|
+
// First lease fails the way a momentarily unreachable pool does.
|
|
104
|
+
child.send(leases === 1
|
|
105
|
+
? {
|
|
106
|
+
type: 'spawn-lease-result',
|
|
107
|
+
leaseId: message.leaseId,
|
|
108
|
+
ok: false,
|
|
109
|
+
error: 'pool channel unavailable',
|
|
110
|
+
code: 'ELEASEFALLBACK',
|
|
111
|
+
}
|
|
112
|
+
: { type: 'spawn-lease-result', leaseId: message.leaseId, ok: true });
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
assert.equal(report.enabled, true);
|
|
116
|
+
assert.equal(report.mode, 'remote-lease');
|
|
117
|
+
assert.equal(report.firstLease, 'ELEASEFALLBACK');
|
|
118
|
+
// Recovery: the shard is still on the daemon budget and the next lease is
|
|
119
|
+
// granted remotely instead of silently using a per-process lane.
|
|
120
|
+
assert.equal(report.enabledAfterFailure, true);
|
|
121
|
+
assert.equal(report.modeAfterFailure, 'remote-lease');
|
|
122
|
+
assert.equal(report.secondLease, 'granted');
|
|
123
|
+
assert.equal(leases, 2);
|
|
124
|
+
});
|
|
@@ -67,7 +67,8 @@
|
|
|
67
67
|
"gemini": "GEMINI_API_KEY",
|
|
68
68
|
"deepseek": "DEEPSEEK_API_KEY",
|
|
69
69
|
"xai": "XAI_API_KEY",
|
|
70
|
-
"opencode-go": "OPENCODE_API_KEY"
|
|
70
|
+
"opencode-go": "OPENCODE_API_KEY",
|
|
71
|
+
"openrouter": "OPENROUTER_API_KEY"
|
|
71
72
|
},
|
|
72
73
|
"personalStateCounters": [
|
|
73
74
|
"behavioralStateFilesCopied",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalize } from 'node:path';
|
|
2
|
+
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
|
2
3
|
|
|
3
4
|
export const SESSION_RUNTIME_WORKER_UNHEALTHY_EVENT = 'mixdog:session-runtime-worker-unhealthy';
|
|
4
5
|
|
|
@@ -67,6 +68,96 @@ export function reportRuntimeDirectoryReadFailure(path, error, now = Date.now())
|
|
|
67
68
|
});
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
// ── Per-shard event-loop lag ───────────────────────────────────────────────
|
|
72
|
+
// Each runtime shard runs provider parsing, transcript projection and tool
|
|
73
|
+
// results on its OWN event loop, so lag is the per-shard saturation signal the
|
|
74
|
+
// daemon cannot see (it only measures itself). Lag is DIAGNOSTIC + ROUTING
|
|
75
|
+
// input, never a kill signal: a saturated shard still owns accepted input and
|
|
76
|
+
// in-flight turns, so the host quarantines it from NEW placement instead of
|
|
77
|
+
// recycling it out from under that work.
|
|
78
|
+
export const RUNTIME_LAG_DEFAULTS = Object.freeze({
|
|
79
|
+
intervalMs: 5_000,
|
|
80
|
+
warnP99Ms: 250,
|
|
81
|
+
degradedP99Ms: 2_000,
|
|
82
|
+
degradedSamples: 3,
|
|
83
|
+
recoverP99Ms: 750,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
function lagMs(value) {
|
|
87
|
+
return Number.isFinite(value) ? Math.round(value / 1e6) : 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Sample this process's event-loop delay on a fixed interval.
|
|
92
|
+
* Returns a stop function; the timer is unref'd so it never holds the process.
|
|
93
|
+
*/
|
|
94
|
+
export function startRuntimeEventLoopLagMonitor({
|
|
95
|
+
intervalMs = RUNTIME_LAG_DEFAULTS.intervalMs,
|
|
96
|
+
resolution = 20,
|
|
97
|
+
onSample = () => {},
|
|
98
|
+
} = {}) {
|
|
99
|
+
const histogram = monitorEventLoopDelay({ resolution });
|
|
100
|
+
histogram.enable();
|
|
101
|
+
const timer = setInterval(() => {
|
|
102
|
+
const sample = {
|
|
103
|
+
p50Ms: lagMs(histogram.percentile(50)),
|
|
104
|
+
p95Ms: lagMs(histogram.percentile(95)),
|
|
105
|
+
p99Ms: lagMs(histogram.percentile(99)),
|
|
106
|
+
maxMs: lagMs(histogram.max),
|
|
107
|
+
meanMs: lagMs(histogram.mean),
|
|
108
|
+
intervalMs,
|
|
109
|
+
at: Date.now(),
|
|
110
|
+
};
|
|
111
|
+
histogram.reset();
|
|
112
|
+
try { onSample(sample); } catch { /* telemetry must never throw upward */ }
|
|
113
|
+
}, intervalMs);
|
|
114
|
+
timer.unref?.();
|
|
115
|
+
return () => {
|
|
116
|
+
clearInterval(timer);
|
|
117
|
+
try { histogram.disable(); } catch {}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Hysteresis state machine over lag samples: a shard becomes `degraded` only
|
|
123
|
+
* after repeated sustained lag and recovers once it drops well below the
|
|
124
|
+
* trigger, so a single GC pause never quarantines a healthy shard.
|
|
125
|
+
*/
|
|
126
|
+
export function createRuntimeLagTracker(overrides = {}) {
|
|
127
|
+
const config = { ...RUNTIME_LAG_DEFAULTS, ...(overrides || {}) };
|
|
128
|
+
let consecutive = 0;
|
|
129
|
+
let degraded = false;
|
|
130
|
+
let sample = null;
|
|
131
|
+
return {
|
|
132
|
+
get config() { return config; },
|
|
133
|
+
get degraded() { return degraded; },
|
|
134
|
+
get sample() { return sample; },
|
|
135
|
+
record(next) {
|
|
136
|
+
sample = next && typeof next === 'object' ? next : null;
|
|
137
|
+
const p99 = Number(sample?.p99Ms) || 0;
|
|
138
|
+
if (p99 >= config.degradedP99Ms) {
|
|
139
|
+
consecutive += 1;
|
|
140
|
+
if (!degraded && consecutive >= config.degradedSamples) {
|
|
141
|
+
degraded = true;
|
|
142
|
+
return { degraded, changed: true, sample, consecutive };
|
|
143
|
+
}
|
|
144
|
+
return { degraded, changed: false, sample, consecutive };
|
|
145
|
+
}
|
|
146
|
+
consecutive = 0;
|
|
147
|
+
if (degraded && p99 <= config.recoverP99Ms) {
|
|
148
|
+
degraded = false;
|
|
149
|
+
return { degraded, changed: true, sample, consecutive };
|
|
150
|
+
}
|
|
151
|
+
return { degraded, changed: false, sample, consecutive };
|
|
152
|
+
},
|
|
153
|
+
reset() {
|
|
154
|
+
consecutive = 0;
|
|
155
|
+
degraded = false;
|
|
156
|
+
sample = null;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
70
161
|
export function reportRuntimeAbortListenerPressure(warning, now = Date.now(), retainedListeners = 0) {
|
|
71
162
|
if (!isCurrentSessionRuntimeWorker()) return false;
|
|
72
163
|
if (!Number.isFinite(Number(retainedListeners)) || Number(retainedListeners) <= 50) {
|
|
@@ -2,10 +2,63 @@ import assert from 'node:assert/strict';
|
|
|
2
2
|
import test from 'node:test';
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
createRuntimeLagTracker,
|
|
5
6
|
recordRuntimeDirectoryReadSuccess,
|
|
6
7
|
reportRuntimeAbortListenerPressure,
|
|
8
|
+
RUNTIME_LAG_DEFAULTS,
|
|
9
|
+
startRuntimeEventLoopLagMonitor,
|
|
7
10
|
} from './session-runtime-health.mjs';
|
|
8
11
|
|
|
12
|
+
test('sustained event-loop lag degrades a shard, a single spike does not', () => {
|
|
13
|
+
const tracker = createRuntimeLagTracker();
|
|
14
|
+
const spike = { p99Ms: RUNTIME_LAG_DEFAULTS.degradedP99Ms + 500 };
|
|
15
|
+
assert.equal(tracker.record(spike).degraded, false);
|
|
16
|
+
assert.equal(tracker.record(spike).degraded, false);
|
|
17
|
+
const degraded = tracker.record(spike);
|
|
18
|
+
assert.equal(degraded.degraded, true);
|
|
19
|
+
assert.equal(degraded.changed, true);
|
|
20
|
+
// Already degraded: no repeated transition.
|
|
21
|
+
assert.equal(tracker.record(spike).changed, false);
|
|
22
|
+
assert.equal(tracker.degraded, true);
|
|
23
|
+
|
|
24
|
+
// Hysteresis: still-elevated lag keeps the quarantine, a clean sample lifts it.
|
|
25
|
+
assert.equal(tracker.record({ p99Ms: RUNTIME_LAG_DEFAULTS.degradedP99Ms - 1 }).degraded, true);
|
|
26
|
+
const recovered = tracker.record({ p99Ms: 10 });
|
|
27
|
+
assert.equal(recovered.degraded, false);
|
|
28
|
+
assert.equal(recovered.changed, true);
|
|
29
|
+
|
|
30
|
+
// A fresh process starts from a clean slate.
|
|
31
|
+
tracker.record(spike);
|
|
32
|
+
tracker.reset();
|
|
33
|
+
assert.equal(tracker.degraded, false);
|
|
34
|
+
assert.equal(tracker.sample, null);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the event-loop lag monitor samples this process and stops cleanly', async () => {
|
|
38
|
+
const samples = [];
|
|
39
|
+
const stop = startRuntimeEventLoopLagMonitor({
|
|
40
|
+
intervalMs: 20,
|
|
41
|
+
onSample: (sample) => samples.push(sample),
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
const deadline = Date.now() + 2_000;
|
|
45
|
+
while (samples.length === 0 && Date.now() < deadline) {
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
47
|
+
}
|
|
48
|
+
} finally {
|
|
49
|
+
stop();
|
|
50
|
+
}
|
|
51
|
+
assert.ok(samples.length > 0, 'monitor produced at least one sample');
|
|
52
|
+
const [sample] = samples;
|
|
53
|
+
for (const key of ['p50Ms', 'p95Ms', 'p99Ms', 'maxMs']) {
|
|
54
|
+
assert.equal(Number.isFinite(sample[key]), true, `${key} is numeric`);
|
|
55
|
+
assert.ok(sample[key] >= 0);
|
|
56
|
+
}
|
|
57
|
+
const seen = samples.length;
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
59
|
+
assert.equal(samples.length, seen, 'no samples after stop');
|
|
60
|
+
});
|
|
61
|
+
|
|
9
62
|
test('repeated AbortSignal listener pressure marks only the session runtime worker unhealthy', () => {
|
|
10
63
|
const originalRuntimeWorkerPid = process.env.MIXDOG_SESSION_RUNTIME_WORKER_PID;
|
|
11
64
|
let detail = null;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { parseDocument } from 'yaml';
|
|
2
|
+
|
|
3
|
+
const SKILL_FRONTMATTER_RE =
|
|
4
|
+
/^(?:\uFEFF)?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
5
|
+
|
|
6
|
+
const scalarText = (value, field) => {
|
|
7
|
+
if (typeof value !== 'string') {
|
|
8
|
+
throw new Error(`Skill ${field} must be a string.`);
|
|
9
|
+
}
|
|
10
|
+
return value.trim();
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function validateSkillName(value) {
|
|
14
|
+
const name = String(value || '').trim();
|
|
15
|
+
if (!name) throw new Error('Skill name is required.');
|
|
16
|
+
if (name.length > 64) throw new Error('Skill name must be 64 characters or fewer.');
|
|
17
|
+
if (!/^[a-z0-9-]+$/.test(name)) {
|
|
18
|
+
throw new Error('Skill name may contain only lowercase letters, numbers, and hyphens.');
|
|
19
|
+
}
|
|
20
|
+
if (name.startsWith('-') || name.endsWith('-') || name.includes('--')) {
|
|
21
|
+
throw new Error('Skill name cannot start or end with a hyphen or contain consecutive hyphens.');
|
|
22
|
+
}
|
|
23
|
+
return name;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function validateSkillDescription(value) {
|
|
27
|
+
const description = String(value || '').trim();
|
|
28
|
+
if (!description) throw new Error('Skill trigger is required.');
|
|
29
|
+
if (description.length > 1024) {
|
|
30
|
+
throw new Error('Skill trigger must be 1024 characters or fewer.');
|
|
31
|
+
}
|
|
32
|
+
return description;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseFrontmatterDocument(markdown) {
|
|
36
|
+
const source = String(markdown || '');
|
|
37
|
+
const match = source.match(SKILL_FRONTMATTER_RE);
|
|
38
|
+
if (!match) throw new Error('SKILL.md must start with YAML frontmatter.');
|
|
39
|
+
const document = parseDocument(match[1], {
|
|
40
|
+
prettyErrors: false,
|
|
41
|
+
strict: true,
|
|
42
|
+
uniqueKeys: true,
|
|
43
|
+
});
|
|
44
|
+
if (document.errors.length) {
|
|
45
|
+
throw new Error(`Invalid SKILL.md frontmatter: ${document.errors[0].message}`);
|
|
46
|
+
}
|
|
47
|
+
const frontmatter = document.toJS({ maxAliasCount: 100 });
|
|
48
|
+
if (!frontmatter || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
|
|
49
|
+
throw new Error('SKILL.md frontmatter must be a YAML mapping.');
|
|
50
|
+
}
|
|
51
|
+
return { source, match, document, frontmatter };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseSkillDocument(markdown) {
|
|
55
|
+
const parsed = parseFrontmatterDocument(markdown);
|
|
56
|
+
const name = validateSkillName(scalarText(parsed.frontmatter.name, 'name'));
|
|
57
|
+
const description = validateSkillDescription(
|
|
58
|
+
scalarText(parsed.frontmatter.description, 'description'),
|
|
59
|
+
);
|
|
60
|
+
return {
|
|
61
|
+
name,
|
|
62
|
+
description,
|
|
63
|
+
body: parsed.source.slice(parsed.match[0].length).replace(/^\r?\n/, ''),
|
|
64
|
+
frontmatter: parsed.frontmatter,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function renderSkillDocument(document, body) {
|
|
69
|
+
const instructions = String(body || '').replace(/\r\n/g, '\n').trim();
|
|
70
|
+
if (!instructions) throw new Error('Skill instructions are required.');
|
|
71
|
+
return `---\n${String(document).trimEnd()}\n---\n\n${instructions}\n`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createSkillDocument({ name, description, body }) {
|
|
75
|
+
const document = parseDocument('');
|
|
76
|
+
document.set('name', validateSkillName(name));
|
|
77
|
+
document.set('description', validateSkillDescription(description));
|
|
78
|
+
return renderSkillDocument(document, body);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function updateSkillDocument(markdown, { name, description, body }) {
|
|
82
|
+
const parsed = parseFrontmatterDocument(markdown);
|
|
83
|
+
parsed.document.set('name', validateSkillName(name));
|
|
84
|
+
parsed.document.set('description', validateSkillDescription(description));
|
|
85
|
+
return renderSkillDocument(parsed.document, body);
|
|
86
|
+
}
|