lazyclaw 5.1.0 → 5.2.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.
- package/agents.mjs +10 -8
- package/chat_window.mjs +39 -0
- package/cli.mjs +280 -55
- package/daemon.mjs +87 -7
- package/mas/agent_turn.mjs +81 -6
- package/mas/index_db.mjs +62 -2
- package/mas/learning.mjs +371 -0
- package/mas/mention_router.mjs +94 -13
- package/mas/orchestra.mjs +56 -0
- package/mas/skill_synth.mjs +36 -2
- package/mas/tool_runner.mjs +8 -1
- package/mas/tools/git.mjs +18 -1
- package/mas/tools/recall.mjs +64 -0
- package/package.json +2 -1
- package/providers/anthropic.mjs +26 -10
- package/providers/orchestrator.mjs +99 -21
- package/providers/registry.mjs +6 -0
- package/providers/tool_use/anthropic.mjs +43 -7
- package/skills.mjs +42 -3
- package/tasks.mjs +24 -1
- package/tui/repl.mjs +6 -0
- package/tui/run_turn.mjs +138 -0
- package/web/dashboard.html +1 -1
package/agents.mjs
CHANGED
|
@@ -80,13 +80,15 @@ function defaultShape(name) {
|
|
|
80
80
|
// for `lazyclaw agent reflect`; 'off' disables writes entirely.
|
|
81
81
|
memoryWrite: 'auto',
|
|
82
82
|
memoryMaxChars: 12 * 1024,
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
// 'manual'
|
|
88
|
-
//
|
|
89
|
-
skillWrite
|
|
83
|
+
// v5 Group A (M3) — self-improving skill synthesis trigger.
|
|
84
|
+
// Default flipped to 'auto' so the learning loop actually closes
|
|
85
|
+
// end-to-end on a fresh install: every agent that finishes a task
|
|
86
|
+
// contributes a SKILL.md unless the operator explicitly opted out
|
|
87
|
+
// ('manual' waits for `lazyclaw agent skill-synth`; 'off' disables).
|
|
88
|
+
// The canonical post-task hook (mas/learning.mjs) also reads
|
|
89
|
+
// `(skillWrite ?? 'auto')`, so v4 records that pre-date this field
|
|
90
|
+
// get the new default without a forced migration.
|
|
91
|
+
skillWrite: 'auto',
|
|
90
92
|
createdAt: new Date().toISOString(),
|
|
91
93
|
updatedAt: new Date().toISOString(),
|
|
92
94
|
};
|
|
@@ -114,7 +116,7 @@ export function registerAgent({ name, displayName, role = '', provider = 'claude
|
|
|
114
116
|
if (!VALID_MEMORY_WRITE.includes(mw)) {
|
|
115
117
|
throw new AgentError(`memoryWrite must be one of ${VALID_MEMORY_WRITE.join(', ')}`, 'AGENT_BAD_MEMORY_WRITE');
|
|
116
118
|
}
|
|
117
|
-
const sw = skillWrite ?? '
|
|
119
|
+
const sw = skillWrite ?? 'auto';
|
|
118
120
|
if (!VALID_SKILL_WRITE.includes(sw)) {
|
|
119
121
|
throw new AgentError(`skillWrite must be one of ${VALID_SKILL_WRITE.join(', ')}`, 'AGENT_BAD_SKILL_WRITE');
|
|
120
122
|
}
|
package/chat_window.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Group B / M6 — chat sliding window helper.
|
|
2
|
+
//
|
|
3
|
+
// A multi-day chat session accumulates hundreds of turns; without a
|
|
4
|
+
// cap the prompt grows linearly and the Anthropic prompt cache
|
|
5
|
+
// breakpoint advances past the useful prefix. The window keeps the
|
|
6
|
+
// last N turns (default 20) AND honours a token budget (default ~8K
|
|
7
|
+
// chars-as-tokens, since `inputTokens` from the model isn't available
|
|
8
|
+
// pre-call). The system message at index 0 is always preserved — it's
|
|
9
|
+
// the agent.role + workspace + skills index that the static cache
|
|
10
|
+
// prefix depends on.
|
|
11
|
+
//
|
|
12
|
+
// Env overrides let operators stretch the window for long-running
|
|
13
|
+
// research sessions without recompiling. Lives in its own file so it
|
|
14
|
+
// can be imported by tests without invoking cli.mjs::main().
|
|
15
|
+
|
|
16
|
+
export const CHAT_WINDOW_TURNS = Number(process.env.LAZYCLAW_CHAT_WINDOW_TURNS) || 20;
|
|
17
|
+
export const CHAT_WINDOW_TOKEN_BUDGET = Number(process.env.LAZYCLAW_CHAT_WINDOW_TOKENS) || 8000;
|
|
18
|
+
|
|
19
|
+
// Trim a hydrated messages[] array to fit the sliding window. Returns
|
|
20
|
+
// { messages, dropped } so the caller can log a one-shot "dropped N
|
|
21
|
+
// older turns" line at session start. The first message is preserved
|
|
22
|
+
// when its role is 'system' (cacheable static prefix). Token budget
|
|
23
|
+
// is approximated as 4 chars/token — accurate enough for a soft cap
|
|
24
|
+
// when the goal is "don't pay for messages the model won't use".
|
|
25
|
+
export function applyChatWindow(messages, { turns = CHAT_WINDOW_TURNS, tokens = CHAT_WINDOW_TOKEN_BUDGET } = {}) {
|
|
26
|
+
if (!Array.isArray(messages) || messages.length === 0) return { messages, dropped: 0 };
|
|
27
|
+
const out = [...messages];
|
|
28
|
+
const sys = out[0]?.role === 'system' ? out.shift() : null;
|
|
29
|
+
const before = out.length;
|
|
30
|
+
// Turn count cap: drop oldest non-system turns until length ≤ turns.
|
|
31
|
+
while (out.length > turns) out.shift();
|
|
32
|
+
// Token budget cap: estimate 4 chars / token, then trim from the
|
|
33
|
+
// front (oldest) until estimated tokens ≤ budget.
|
|
34
|
+
const estTokens = (arr) => Math.ceil(arr.reduce((n, m) => n + String(m.content || '').length, 0) / 4);
|
|
35
|
+
while (out.length > 1 && estTokens(out) > tokens) out.shift();
|
|
36
|
+
const dropped = before - out.length;
|
|
37
|
+
if (sys) out.unshift(sys);
|
|
38
|
+
return { messages: out, dropped };
|
|
39
|
+
}
|
package/cli.mjs
CHANGED
|
@@ -8,6 +8,12 @@ import { pathToFileURL } from 'node:url';
|
|
|
8
8
|
import { resolveSandbox, listBackends } from './sandbox/index.mjs';
|
|
9
9
|
// Phase G: defaultConfigDir for personality subcommand (spec §9, decision C7).
|
|
10
10
|
import { defaultConfigDir as _persDefaultCfg } from './memory.mjs';
|
|
11
|
+
// Group B / M6 — chat sliding window. Lives in its own module so
|
|
12
|
+
// tests can import the helper without invoking cli.mjs::main().
|
|
13
|
+
import { applyChatWindow as _applyChatWindow, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOKEN_BUDGET } from './chat_window.mjs';
|
|
14
|
+
// v5 Group C (C7) — shared chat-turn streaming closure. Single source
|
|
15
|
+
// of truth for both the ink REPL path and the legacy readline path.
|
|
16
|
+
import { makeRunTurn as _chatRunTurnFactory } from './tui/run_turn.mjs';
|
|
11
17
|
|
|
12
18
|
async function loadEngine() {
|
|
13
19
|
return import('./workflow/persistent.mjs');
|
|
@@ -858,6 +864,58 @@ async function cmdDoctor() {
|
|
|
858
864
|
if (cfg.provider && !PROVIDERS_HAS(_registryMod.PROVIDERS, cfg.provider)) {
|
|
859
865
|
issues.push(`unknown provider "${cfg.provider}" — registered: ${Object.keys(_registryMod.PROVIDERS).join(', ')}`);
|
|
860
866
|
}
|
|
867
|
+
// C12 — MinGit / Windows safety net. mas/tools/git.mjs shells out to
|
|
868
|
+
// `git`; on a stripped Windows PATH (no Git-for-Windows installed) or
|
|
869
|
+
// a minimal Docker base image, that spawnSync ENOENTs and any agent
|
|
870
|
+
// task touching the git tool fails opaquely. Probe up-front so
|
|
871
|
+
// `lazyclaw doctor` surfaces a clean diagnostic and the operator can
|
|
872
|
+
// install the binary before they trip over it.
|
|
873
|
+
let gitInfo = null;
|
|
874
|
+
try {
|
|
875
|
+
const { spawnSync } = await import('node:child_process');
|
|
876
|
+
const gitExe = process.env.GIT_EXECUTABLE || 'git';
|
|
877
|
+
const probe = spawnSync(gitExe, ['--version'], { encoding: 'utf8' });
|
|
878
|
+
if (probe.error && probe.error.code === 'ENOENT') {
|
|
879
|
+
issues.push('git binary not found on PATH — `mas/tools/git.mjs` will fail. Install Git: macOS `xcode-select --install`; Linux `apt install git` / `yum install git`; Windows Git-for-Windows (https://git-scm.com/download/win). Or set the GIT_EXECUTABLE env var to an explicit path.');
|
|
880
|
+
gitInfo = { ok: false, code: 'ENOENT' };
|
|
881
|
+
} else if (probe.status !== 0) {
|
|
882
|
+
issues.push(`git --version exited ${probe.status} (${(probe.stderr || '').trim().slice(0, 200)})`);
|
|
883
|
+
gitInfo = { ok: false, status: probe.status, stderr: (probe.stderr || '').trim().slice(0, 200) };
|
|
884
|
+
} else {
|
|
885
|
+
gitInfo = { ok: true, version: (probe.stdout || '').trim() };
|
|
886
|
+
}
|
|
887
|
+
} catch (e) {
|
|
888
|
+
gitInfo = { ok: false, error: e?.message || String(e) };
|
|
889
|
+
}
|
|
890
|
+
// m11 — stale index probe. mas/index_db.mjs write-through hooks
|
|
891
|
+
// log failures to <configDir>/index-failures.jsonl; surface a recent
|
|
892
|
+
// count so the operator notices a silently-degraded index. Best-effort:
|
|
893
|
+
// missing file → 0 failures (the common case).
|
|
894
|
+
let indexInfo = null;
|
|
895
|
+
try {
|
|
896
|
+
const cfgDir = path.dirname(configPath());
|
|
897
|
+
const auditFile = path.join(cfgDir, 'index-failures.jsonl');
|
|
898
|
+
if (fs.existsSync(auditFile)) {
|
|
899
|
+
const raw = fs.readFileSync(auditFile, 'utf8');
|
|
900
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
901
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
902
|
+
let recent = 0;
|
|
903
|
+
for (const line of lines) {
|
|
904
|
+
try {
|
|
905
|
+
const entry = JSON.parse(line);
|
|
906
|
+
if (entry?.ts && new Date(entry.ts).getTime() >= cutoff) recent++;
|
|
907
|
+
} catch { /* skip malformed */ }
|
|
908
|
+
}
|
|
909
|
+
indexInfo = { failuresLast24h: recent, totalFailures: lines.length };
|
|
910
|
+
if (recent > 0) {
|
|
911
|
+
issues.push(`${recent} index write failure(s) in the last 24h — run \`lazyclaw index rebuild\` to recover.`);
|
|
912
|
+
}
|
|
913
|
+
} else {
|
|
914
|
+
indexInfo = { failuresLast24h: 0, totalFailures: 0 };
|
|
915
|
+
}
|
|
916
|
+
} catch (e) {
|
|
917
|
+
indexInfo = { error: e?.message || String(e) };
|
|
918
|
+
}
|
|
861
919
|
// Workflow state health — informational counters that show whether
|
|
862
920
|
// the user has any failed or stuck workflow runs to attend to. We
|
|
863
921
|
// don't push these to `issues` (a stuck workflow doesn't break the
|
|
@@ -901,6 +959,8 @@ async function cmdDoctor() {
|
|
|
901
959
|
issues,
|
|
902
960
|
knownProviders: Object.keys(_registryMod.PROVIDERS),
|
|
903
961
|
workflows,
|
|
962
|
+
git: gitInfo,
|
|
963
|
+
index: indexInfo,
|
|
904
964
|
timestamp: new Date().toISOString(),
|
|
905
965
|
};
|
|
906
966
|
console.log(JSON.stringify(out, null, 2));
|
|
@@ -1694,6 +1754,7 @@ function _printChatBanner(activeProvName, activeModel, version) {
|
|
|
1694
1754
|
process.stdout.write(lines.join('\n') + '\n');
|
|
1695
1755
|
}
|
|
1696
1756
|
|
|
1757
|
+
|
|
1697
1758
|
// Interactive provider/model picker. Used on first run (no config) or
|
|
1698
1759
|
// when the user passes --pick. Falls back to plain stdin reads when
|
|
1699
1760
|
// stdout isn't a TTY (CI/script callers should pass --non-interactive
|
|
@@ -2469,9 +2530,113 @@ async function cmdChat(flags = {}) {
|
|
|
2469
2530
|
skills: skillGroups,
|
|
2470
2531
|
};
|
|
2471
2532
|
void renderSplashToString; // surfaced for tests; runtime uses <Splash/>
|
|
2533
|
+
|
|
2534
|
+
// C7 — minimal chat-session state for the ink path so runTurn can
|
|
2535
|
+
// talk to the provider (the legacy readline path below sets up the
|
|
2536
|
+
// same shape — kept duplicated here intentionally so the ink branch
|
|
2537
|
+
// remains self-contained and the legacy path stays byte-identical).
|
|
2538
|
+
// Slash commands aren't wired into the ink REPL yet (v5.1 follow-up);
|
|
2539
|
+
// until then, system-prompt composition / --session resume happen
|
|
2540
|
+
// identically to the legacy path.
|
|
2541
|
+
let _inkSandboxSpec = null;
|
|
2542
|
+
if (flags.sandbox) {
|
|
2543
|
+
const sb = await import('./sandbox.mjs');
|
|
2544
|
+
try { _inkSandboxSpec = sb.parseSandboxSpec(flags.sandbox, flags); }
|
|
2545
|
+
catch (err) { console.error(`error: ${err.message}`); process.exit(2); }
|
|
2546
|
+
}
|
|
2547
|
+
let _inkSessionId = flags.session || null;
|
|
2548
|
+
const _inkCfgDir = path.dirname(configPath());
|
|
2549
|
+
let _inkMessages = _inkSessionId
|
|
2550
|
+
? sessionsMod.loadTurns(_inkSessionId, _inkCfgDir).map((t) => ({ role: t.role, content: t.content }))
|
|
2551
|
+
: [];
|
|
2552
|
+
if (_inkMessages.length > 0) {
|
|
2553
|
+
const cfgChat = cfg.chat || {};
|
|
2554
|
+
const winTurns = Number(cfgChat.windowTurns) || CHAT_WINDOW_TURNS;
|
|
2555
|
+
const winTokens = Number(cfgChat.windowTokens) || CHAT_WINDOW_TOKEN_BUDGET;
|
|
2556
|
+
const { messages: trimmed } = _applyChatWindow(_inkMessages, { turns: winTurns, tokens: winTokens });
|
|
2557
|
+
_inkMessages = trimmed;
|
|
2558
|
+
}
|
|
2559
|
+
// System prompt composition — mirrors the legacy path's sysParts logic.
|
|
2560
|
+
const _inkSkillNames = (flags.skill ? String(flags.skill) : (Array.isArray(cfg.skills) ? cfg.skills.join(',') : ''))
|
|
2561
|
+
.split(',').map((s) => s.trim()).filter(Boolean);
|
|
2562
|
+
const _inkWorkspaceName = flags.workspace || cfg.workspace || '';
|
|
2563
|
+
const _inkSysParts = [];
|
|
2564
|
+
try {
|
|
2565
|
+
const { composePromptStack } = await import('./mas/prompt_stack.mjs');
|
|
2566
|
+
const stacked = composePromptStack({
|
|
2567
|
+
cfgDir: _inkCfgDir,
|
|
2568
|
+
agent: { name: 'chat', role: '' },
|
|
2569
|
+
workspace: _inkWorkspaceName,
|
|
2570
|
+
});
|
|
2571
|
+
if (stacked && stacked.trim()) _inkSysParts.push(stacked);
|
|
2572
|
+
} catch { /* never block chat start on stack composition */ }
|
|
2573
|
+
if (_inkWorkspaceName && !_inkMessages.some((m) => m.role === 'system')) {
|
|
2574
|
+
try {
|
|
2575
|
+
const ws = await import('./workspace.mjs');
|
|
2576
|
+
const wsPrompt = ws.composeWorkspacePrompt(_inkCfgDir, _inkWorkspaceName);
|
|
2577
|
+
if (wsPrompt) _inkSysParts.push(wsPrompt);
|
|
2578
|
+
} catch (err) { console.error(`workspace error: ${err.message}`); process.exit(2); }
|
|
2579
|
+
}
|
|
2580
|
+
if (_inkSkillNames.length > 0 && !_inkMessages.some((m) => m.role === 'system')) {
|
|
2581
|
+
try {
|
|
2582
|
+
const sys = skillsMod.composeSystemPrompt(_inkSkillNames, _inkCfgDir);
|
|
2583
|
+
if (sys) _inkSysParts.push(sys);
|
|
2584
|
+
} catch (err) { console.error(`skill error: ${err.message}`); process.exit(2); }
|
|
2585
|
+
}
|
|
2586
|
+
if (_inkSysParts.length && !_inkMessages.some((m) => m.role === 'system')) {
|
|
2587
|
+
const merged = _inkSysParts.join('\n\n---\n\n');
|
|
2588
|
+
_inkMessages.unshift({ role: 'system', content: merged });
|
|
2589
|
+
if (_inkSessionId) sessionsMod.appendTurn(_inkSessionId, 'system', merged, _inkCfgDir);
|
|
2590
|
+
}
|
|
2591
|
+
let _inkRunningUsage = null;
|
|
2592
|
+
const _inkAccumulateUsage = (u) => {
|
|
2593
|
+
if (!u) return;
|
|
2594
|
+
if (!_inkRunningUsage) _inkRunningUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, turnsWithUsage: 0 };
|
|
2595
|
+
_inkRunningUsage.inputTokens += Number(u.inputTokens) || 0;
|
|
2596
|
+
_inkRunningUsage.outputTokens += Number(u.outputTokens) || 0;
|
|
2597
|
+
_inkRunningUsage.totalTokens += Number(u.totalTokens) || ((Number(u.inputTokens) || 0) + (Number(u.outputTokens) || 0));
|
|
2598
|
+
_inkRunningUsage.turnsWithUsage += 1;
|
|
2599
|
+
};
|
|
2600
|
+
const _inkChatStartedAt = Date.now();
|
|
2601
|
+
const _inkSyntheticChatSessionId = `chat-${process.pid}-${_inkChatStartedAt}`;
|
|
2602
|
+
const _inkPersistTurn = (role, content) => {
|
|
2603
|
+
if (_inkSessionId) {
|
|
2604
|
+
sessionsMod.appendTurn(_inkSessionId, role, content, _inkCfgDir);
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
try {
|
|
2608
|
+
import('./memory.mjs').then((m) => {
|
|
2609
|
+
try { m.appendRecent(_inkSyntheticChatSessionId, role, content, _inkCfgDir); }
|
|
2610
|
+
catch { /* swallow */ }
|
|
2611
|
+
}).catch(() => {});
|
|
2612
|
+
} catch { /* swallow */ }
|
|
2613
|
+
};
|
|
2614
|
+
const _inkCtx = {
|
|
2615
|
+
cfg,
|
|
2616
|
+
cfgDir: _inkCfgDir,
|
|
2617
|
+
sandboxSpec: _inkSandboxSpec,
|
|
2618
|
+
syntheticChatSessionId: _inkSyntheticChatSessionId,
|
|
2619
|
+
getMessages: () => _inkMessages,
|
|
2620
|
+
getProv: () => prov,
|
|
2621
|
+
getActiveProvName: () => activeProvName,
|
|
2622
|
+
getActiveModel: () => activeModel,
|
|
2623
|
+
getSessionId: () => _inkSessionId,
|
|
2624
|
+
persistTurn: _inkPersistTurn,
|
|
2625
|
+
accumulateUsage: _inkAccumulateUsage,
|
|
2626
|
+
resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
|
|
2627
|
+
};
|
|
2628
|
+
// v5.0.10: write streamed chunks straight to process.stdout. Ink
|
|
2629
|
+
// owns the screen, so interleaved stdout writes can produce some
|
|
2630
|
+
// visual jank — accepted trade for unblocking the chat loop. v5.1
|
|
2631
|
+
// TODO: route through a ref'd scrollback <Static/> region in
|
|
2632
|
+
// ReplApp so Ink owns all output.
|
|
2633
|
+
const _inkRunTurn = _chatRunTurnFactory({
|
|
2634
|
+
ctx: _inkCtx,
|
|
2635
|
+
writeFn: (chunk) => process.stdout.write(chunk),
|
|
2636
|
+
});
|
|
2472
2637
|
const ink = render(/* @__PURE__ */ React.createElement(ReplApp, {
|
|
2473
2638
|
splashProps,
|
|
2474
|
-
runTurn:
|
|
2639
|
+
runTurn: _inkRunTurn,
|
|
2475
2640
|
}));
|
|
2476
2641
|
await ink.waitUntilExit();
|
|
2477
2642
|
return;
|
|
@@ -2529,6 +2694,24 @@ async function cmdChat(flags = {}) {
|
|
|
2529
2694
|
? sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }))
|
|
2530
2695
|
: [];
|
|
2531
2696
|
|
|
2697
|
+
// M6 — apply sliding window at session start. Long-running sessions
|
|
2698
|
+
// (50+ turns) used to ship every prior turn to the provider every
|
|
2699
|
+
// request; we now keep at most CHAT_WINDOW_TURNS turns (default 20)
|
|
2700
|
+
// plus the system message. Operators can override via env. The
|
|
2701
|
+
// per-session log on disk is untouched — only the in-memory prompt
|
|
2702
|
+
// window is trimmed. We log to stderr once at session start so the
|
|
2703
|
+
// user knows context was dropped.
|
|
2704
|
+
if (messages.length > 0) {
|
|
2705
|
+
const cfgChat = cfg.chat || {};
|
|
2706
|
+
const winTurns = Number(cfgChat.windowTurns) || CHAT_WINDOW_TURNS;
|
|
2707
|
+
const winTokens = Number(cfgChat.windowTokens) || CHAT_WINDOW_TOKEN_BUDGET;
|
|
2708
|
+
const { messages: trimmed, dropped } = _applyChatWindow(messages, { turns: winTurns, tokens: winTokens });
|
|
2709
|
+
if (dropped > 0) {
|
|
2710
|
+
process.stderr.write(`[chat] sliding window: dropped ${dropped} older turn(s), ${trimmed.length} kept\n`);
|
|
2711
|
+
}
|
|
2712
|
+
messages = trimmed;
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2532
2715
|
// --skill (comma-separated names) composes into a system message at the
|
|
2533
2716
|
// head of the conversation. Same shape as `agent --skill`. Defaults from
|
|
2534
2717
|
// config.skills array when --skill not passed. We only inject if no
|
|
@@ -2542,6 +2725,21 @@ async function cmdChat(flags = {}) {
|
|
|
2542
2725
|
// a faithful preview.
|
|
2543
2726
|
const workspaceName = flags.workspace || cfg.workspace || '';
|
|
2544
2727
|
const sysParts = [];
|
|
2728
|
+
// v5 (canonical decision C5) — prepend the 8-layer composePromptStack
|
|
2729
|
+
// output. Falls back silently to no-op when the configDir has none of
|
|
2730
|
+
// the source files present (fresh install) so chat-start stays
|
|
2731
|
+
// byte-identical to the v4 shape until a user authors USER.md or a
|
|
2732
|
+
// personality. Wrapped in try/catch — chat start must never break on
|
|
2733
|
+
// a stack composition error.
|
|
2734
|
+
try {
|
|
2735
|
+
const { composePromptStack } = await import('./mas/prompt_stack.mjs');
|
|
2736
|
+
const stacked = composePromptStack({
|
|
2737
|
+
cfgDir,
|
|
2738
|
+
agent: { name: 'chat', role: '' },
|
|
2739
|
+
workspace: workspaceName,
|
|
2740
|
+
});
|
|
2741
|
+
if (stacked && stacked.trim()) sysParts.push(stacked);
|
|
2742
|
+
} catch { /* never block chat start on stack composition */ }
|
|
2545
2743
|
if (workspaceName && !messages.some(m => m.role === 'system')) {
|
|
2546
2744
|
try {
|
|
2547
2745
|
const ws = await import('./workspace.mjs');
|
|
@@ -2582,11 +2780,56 @@ async function cmdChat(flags = {}) {
|
|
|
2582
2780
|
runningUsage.totalTokens += Number(u.totalTokens) || ((Number(u.inputTokens) || 0) + (Number(u.outputTokens) || 0));
|
|
2583
2781
|
runningUsage.turnsWithUsage += 1;
|
|
2584
2782
|
};
|
|
2783
|
+
// v5 Group A (M2): always-on synthetic session id so an unsessioned
|
|
2784
|
+
// chat still populates memory/recent.jsonl. Without this, the nudge
|
|
2785
|
+
// loop never saw repeated prompts in chat sessions that didn't pass
|
|
2786
|
+
// --session, and `nudge.suggest_skill` clusters silently lost ~95%
|
|
2787
|
+
// of their evidence. The `chat-<pid>-<startTs>` prefix keeps the
|
|
2788
|
+
// synthetic id distinguishable from real session ids on disk.
|
|
2789
|
+
const chatStartedAt = Date.now();
|
|
2790
|
+
const _syntheticChatSessionId = `chat-${process.pid}-${chatStartedAt}`;
|
|
2585
2791
|
const persistTurn = (role, content) => {
|
|
2586
|
-
if (
|
|
2587
|
-
|
|
2792
|
+
if (sessionId) {
|
|
2793
|
+
sessionsMod.appendTurn(sessionId, role, content, cfgDir);
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
// No --session: don't touch sessions/<id>.jsonl, but DO append to
|
|
2797
|
+
// memory/recent.jsonl directly so the nudge loop can cluster on
|
|
2798
|
+
// unsessioned chats. Best-effort — a broken memory module must not
|
|
2799
|
+
// break a chat turn.
|
|
2800
|
+
try {
|
|
2801
|
+
import('./memory.mjs').then((m) => {
|
|
2802
|
+
try { m.appendRecent(_syntheticChatSessionId, role, content, cfgDir); }
|
|
2803
|
+
catch { /* swallow */ }
|
|
2804
|
+
}).catch(() => {});
|
|
2805
|
+
} catch { /* swallow */ }
|
|
2588
2806
|
};
|
|
2589
2807
|
|
|
2808
|
+
// C7 — shared runTurn closure for the legacy path. The same factory
|
|
2809
|
+
// backs the ink path above; both call sites get one set of bugs.
|
|
2810
|
+
// Getters close over the *current* binding of sessionId, prov,
|
|
2811
|
+
// activeProvName, activeModel — so a mid-session /provider switch
|
|
2812
|
+
// takes effect on the very next turn.
|
|
2813
|
+
const _legacyCtx = {
|
|
2814
|
+
cfg,
|
|
2815
|
+
cfgDir,
|
|
2816
|
+
sandboxSpec,
|
|
2817
|
+
syntheticChatSessionId: _syntheticChatSessionId,
|
|
2818
|
+
getMessages: () => messages,
|
|
2819
|
+
getProv: () => prov,
|
|
2820
|
+
getActiveProvName: () => activeProvName,
|
|
2821
|
+
getActiveModel: () => activeModel,
|
|
2822
|
+
getSessionId: () => sessionId,
|
|
2823
|
+
persistTurn,
|
|
2824
|
+
accumulateUsage,
|
|
2825
|
+
resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
|
|
2826
|
+
onCharsSent: (n) => { charsSent += n; },
|
|
2827
|
+
};
|
|
2828
|
+
const runTurn = _chatRunTurnFactory({
|
|
2829
|
+
ctx: _legacyCtx,
|
|
2830
|
+
writeFn: (chunk) => process.stdout.write(chunk),
|
|
2831
|
+
});
|
|
2832
|
+
|
|
2590
2833
|
const handleSlash = async (line) => {
|
|
2591
2834
|
const cmd = line.split(/\s+/)[0];
|
|
2592
2835
|
switch (cmd) {
|
|
@@ -3172,6 +3415,35 @@ async function cmdChat(flags = {}) {
|
|
|
3172
3415
|
return true;
|
|
3173
3416
|
}
|
|
3174
3417
|
case '/exit': {
|
|
3418
|
+
// v5 Group A (C4): fire one updateUserModel call before exit so
|
|
3419
|
+
// the Honcho-style USER.md captures the durable facts surfaced
|
|
3420
|
+
// in this session. Wrapped in a 3-second timeout so a slow
|
|
3421
|
+
// trainer never makes /exit hang. Best-effort: failure logs are
|
|
3422
|
+
// suppressed so we don't disturb the clean shutdown.
|
|
3423
|
+
try {
|
|
3424
|
+
const turns = sessionId
|
|
3425
|
+
? sessionsMod.loadTurns(sessionId, cfgDir)
|
|
3426
|
+
: messages.map((t) => ({ role: t.role, content: t.content }));
|
|
3427
|
+
if (turns && turns.length) {
|
|
3428
|
+
const trainer = (typeof _registryMod?.resolveTrainer === 'function')
|
|
3429
|
+
? _registryMod.resolveTrainer(cfg)
|
|
3430
|
+
: { provider: activeProvName, model: activeModel };
|
|
3431
|
+
const userModelPromise = import('./mas/user_modeler.mjs').then((m) =>
|
|
3432
|
+
m.updateUserModel({
|
|
3433
|
+
sessionTurns: turns,
|
|
3434
|
+
provider: trainer.provider,
|
|
3435
|
+
model: trainer.model,
|
|
3436
|
+
apiKey: _resolveAuthKey(cfg, trainer.provider),
|
|
3437
|
+
baseUrl: _resolveBaseUrl(trainer.provider),
|
|
3438
|
+
configDir: cfgDir,
|
|
3439
|
+
}),
|
|
3440
|
+
).catch(() => null);
|
|
3441
|
+
await Promise.race([
|
|
3442
|
+
userModelPromise,
|
|
3443
|
+
new Promise((resolve) => setTimeout(resolve, 3000)),
|
|
3444
|
+
]);
|
|
3445
|
+
}
|
|
3446
|
+
} catch { /* /exit must never hang or throw */ }
|
|
3175
3447
|
return 'EXIT';
|
|
3176
3448
|
}
|
|
3177
3449
|
default:
|
|
@@ -3189,10 +3461,6 @@ async function cmdChat(flags = {}) {
|
|
|
3189
3461
|
if (useTerminal) rl.prompt();
|
|
3190
3462
|
continue;
|
|
3191
3463
|
}
|
|
3192
|
-
messages.push({ role: 'user', content: text });
|
|
3193
|
-
charsSent += text.length;
|
|
3194
|
-
persistTurn('user', text);
|
|
3195
|
-
let acc = '';
|
|
3196
3464
|
// Per-turn AbortController. Ctrl+C during a stream aborts THIS turn
|
|
3197
3465
|
// and returns to the prompt instead of killing the process. Outside
|
|
3198
3466
|
// a stream, Ctrl+C still terminates (we restore the default handler
|
|
@@ -3210,55 +3478,12 @@ async function cmdChat(flags = {}) {
|
|
|
3210
3478
|
// gaps between CJK characters (visible in user-reported screen
|
|
3211
3479
|
// captures of Korean replies).
|
|
3212
3480
|
if (useTerminal) _ghost.suspend();
|
|
3213
|
-
// Buffered writer — coalesce single-character streaming chunks
|
|
3214
|
-
// into ~30 ms windows. Two reasons:
|
|
3215
|
-
// 1. Korean / Japanese / Chinese tokens often arrive as one
|
|
3216
|
-
// character per chunk. Each individual `process.stdout.write`
|
|
3217
|
-
// can race against terminal redraw on a wide-cell character,
|
|
3218
|
-
// producing the same "visible space between every character"
|
|
3219
|
-
// symptom the suspend above also addresses.
|
|
3220
|
-
// 2. Far fewer syscalls. A 200-char Korean reply was ~200
|
|
3221
|
-
// separate writes; this collapses to ~7-10.
|
|
3222
|
-
let _writeBuf = '';
|
|
3223
|
-
let _writeTimer = null;
|
|
3224
|
-
const _flush = () => {
|
|
3225
|
-
if (_writeBuf) { process.stdout.write(_writeBuf); _writeBuf = ''; }
|
|
3226
|
-
_writeTimer = null;
|
|
3227
|
-
};
|
|
3228
|
-
const _writeChunk = (s) => {
|
|
3229
|
-
_writeBuf += s;
|
|
3230
|
-
if (!_writeTimer) _writeTimer = setTimeout(_flush, 30);
|
|
3231
|
-
};
|
|
3232
3481
|
try {
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
onUsage: accumulateUsage,
|
|
3239
|
-
})) {
|
|
3240
|
-
_writeChunk(chunk);
|
|
3241
|
-
acc += chunk;
|
|
3242
|
-
}
|
|
3243
|
-
// Drain anything still buffered before the trailing newline so
|
|
3244
|
-
// the prompt lands on its own line cleanly.
|
|
3245
|
-
if (_writeTimer) clearTimeout(_writeTimer);
|
|
3246
|
-
_flush();
|
|
3247
|
-
process.stdout.write('\n');
|
|
3248
|
-
messages.push({ role: 'assistant', content: acc });
|
|
3249
|
-
persistTurn('assistant', acc);
|
|
3250
|
-
} catch (err) {
|
|
3251
|
-
// Drain pending buffer so partial reply stays on screen even
|
|
3252
|
-
// when the stream errors mid-flight.
|
|
3253
|
-
if (_writeTimer) clearTimeout(_writeTimer);
|
|
3254
|
-
_flush();
|
|
3255
|
-
// ABORT errors are user-initiated; partial assistant output is
|
|
3256
|
-
// discarded (we don't append a half-reply to the message history
|
|
3257
|
-
// because the next turn would treat it as a complete reply and
|
|
3258
|
-
// give odd context to the model).
|
|
3259
|
-
if (err?.code !== 'ABORT' && !turnAc.signal.aborted) {
|
|
3260
|
-
process.stdout.write(`error: ${err?.message || String(err)}\n`);
|
|
3261
|
-
}
|
|
3482
|
+
// C7 — single source of truth for the streaming + persist +
|
|
3483
|
+
// post-task learning loop. The factory handles the user-msg push,
|
|
3484
|
+
// 30 ms buffered writer (CJK-safe), assistant-msg push,
|
|
3485
|
+
// persistTurn for both turns, and the post-task learning hook.
|
|
3486
|
+
await runTurn(text, turnAc.signal);
|
|
3262
3487
|
} finally {
|
|
3263
3488
|
process.off('SIGINT', onSigint);
|
|
3264
3489
|
if (useTerminal) _ghost.resume();
|
package/daemon.mjs
CHANGED
|
@@ -339,6 +339,22 @@ export function makeHandler(ctx) {
|
|
|
339
339
|
// having to subscribe to the SSE bus.
|
|
340
340
|
const SUGG_RING_MAX = 50;
|
|
341
341
|
const nudgeSuggestionsRing = [];
|
|
342
|
+
// v5 Group A (M2): when the operator opts in via
|
|
343
|
+
// cfg.orchestra.learning.autoSynthOnNudge, every emitted cluster
|
|
344
|
+
// also triggers runLearning('nudge', { cluster }) so the canonical
|
|
345
|
+
// funnel can distill the repeated prompt into a SKILL.md. The SSE
|
|
346
|
+
// broadcast and ring buffer still fire unconditionally so the
|
|
347
|
+
// dashboard sees suggestions even when auto-synth is disabled
|
|
348
|
+
// (the conservative default — we never want a noisy chat to bloat
|
|
349
|
+
// the skills/ dir without the operator's blessing).
|
|
350
|
+
let _learningHub = null;
|
|
351
|
+
const _readAutoSynthOnNudge = () => {
|
|
352
|
+
try {
|
|
353
|
+
const cfg = typeof ctx.readConfig === 'function' ? ctx.readConfig() : {};
|
|
354
|
+
const orch = cfg?.orchestra || cfg?.orchestrator || {};
|
|
355
|
+
return !!(orch.learning && orch.learning.autoSynthOnNudge);
|
|
356
|
+
} catch { return false; }
|
|
357
|
+
};
|
|
342
358
|
const _nudgeLoop = nudge.startNudgeLoop({
|
|
343
359
|
configDir: gwConfigDir,
|
|
344
360
|
emit: (event) => {
|
|
@@ -350,6 +366,26 @@ export function makeHandler(ctx) {
|
|
|
350
366
|
nudgeSuggestionsRing.length = SUGG_RING_MAX;
|
|
351
367
|
}
|
|
352
368
|
} catch { /* ignore */ }
|
|
369
|
+
if (_readAutoSynthOnNudge()) {
|
|
370
|
+
(async () => {
|
|
371
|
+
try {
|
|
372
|
+
if (!_learningHub) _learningHub = await import('./mas/learning.mjs');
|
|
373
|
+
const cfg = typeof ctx.readConfig === 'function' ? ctx.readConfig() : {};
|
|
374
|
+
// Wrap the single cluster in the items[] shape runLearning('nudge')
|
|
375
|
+
// expects so the representative task is the cluster's sample
|
|
376
|
+
// prompt.
|
|
377
|
+
const itemTask = { id: `nudge-${event.ts || Date.now()}`, title: 'nudge cluster', turns: [{ agent: 'user', text: event.cluster?.sample || '' }] };
|
|
378
|
+
await _learningHub.runLearning('nudge', {
|
|
379
|
+
cluster: { items: [itemTask] },
|
|
380
|
+
configDir: gwConfigDir,
|
|
381
|
+
cfg,
|
|
382
|
+
});
|
|
383
|
+
} catch (err) {
|
|
384
|
+
try { logger?.warn?.('nudge_learning_failed', { err: err.message }); }
|
|
385
|
+
catch { /* ignore */ }
|
|
386
|
+
}
|
|
387
|
+
})();
|
|
388
|
+
}
|
|
353
389
|
},
|
|
354
390
|
logger,
|
|
355
391
|
});
|
|
@@ -510,11 +546,13 @@ export function makeHandler(ctx) {
|
|
|
510
546
|
return writeJson(res, 200, result);
|
|
511
547
|
}
|
|
512
548
|
case route === 'GET /health':
|
|
549
|
+
case route === 'GET /healthz':
|
|
513
550
|
// Conventional liveness check — always 200 if the process
|
|
514
551
|
// is alive enough to hit the route. No config inspection
|
|
515
552
|
// (use /doctor for readiness), no provider probing — this
|
|
516
553
|
// is the "is the daemon up?" probe that load balancers
|
|
517
|
-
// and watchdog scripts expect at this path.
|
|
554
|
+
// and watchdog scripts expect at this path. m15: K8s
|
|
555
|
+
// readiness probes default to /healthz so we alias.
|
|
518
556
|
return writeJson(res, 200, {
|
|
519
557
|
ok: true,
|
|
520
558
|
status: 'alive',
|
|
@@ -1550,7 +1588,10 @@ export function makeHandler(ctx) {
|
|
|
1550
1588
|
// 404 when the file is missing so the caller can branch.
|
|
1551
1589
|
// 400 when the name fails skillPath validation (path traversal,
|
|
1552
1590
|
// dotfile, etc.) — same protections as the CLI.
|
|
1553
|
-
|
|
1591
|
+
// m13 — decodeURIComponent before validation (see PUT below).
|
|
1592
|
+
let name;
|
|
1593
|
+
try { name = decodeURIComponent(skillMatch[1]); }
|
|
1594
|
+
catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
|
|
1554
1595
|
try {
|
|
1555
1596
|
const cfgDir = ctx.sessionsDirGetter();
|
|
1556
1597
|
const file = skillPath(name, cfgDir);
|
|
@@ -1570,7 +1611,13 @@ export function makeHandler(ctx) {
|
|
|
1570
1611
|
// 201 on first write, 200 on overwrite (caller can branch on
|
|
1571
1612
|
// the status if they care about idempotency vs newness).
|
|
1572
1613
|
// 400 on invalid name (skillPath validation) or oversize body.
|
|
1573
|
-
|
|
1614
|
+
// m13 — decodeURIComponent the segment before validation so
|
|
1615
|
+
// a request like `PUT /skills/foo%2Fbar` is rejected as a path-
|
|
1616
|
+
// separator (slash) rather than letting the literal-percent
|
|
1617
|
+
// filename slip through.
|
|
1618
|
+
let name;
|
|
1619
|
+
try { name = decodeURIComponent(skillMatch[1]); }
|
|
1620
|
+
catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
|
|
1574
1621
|
const cfgDir = ctx.sessionsDirGetter();
|
|
1575
1622
|
let priorExists = false;
|
|
1576
1623
|
try {
|
|
@@ -1598,7 +1645,10 @@ export function makeHandler(ctx) {
|
|
|
1598
1645
|
// existed or not, mirroring DELETE /sessions/<id>. The body
|
|
1599
1646
|
// reports `removed: true|false` so callers can branch when
|
|
1600
1647
|
// they care.
|
|
1601
|
-
|
|
1648
|
+
// m13 — decodeURIComponent before validation (see PUT below).
|
|
1649
|
+
let name;
|
|
1650
|
+
try { name = decodeURIComponent(skillMatch[1]); }
|
|
1651
|
+
catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
|
|
1602
1652
|
const cfgDir = ctx.sessionsDirGetter();
|
|
1603
1653
|
try {
|
|
1604
1654
|
const file = skillPath(name, cfgDir);
|
|
@@ -1612,11 +1662,22 @@ export function makeHandler(ctx) {
|
|
|
1612
1662
|
case req.method === 'DELETE' && !!sessionMatch: {
|
|
1613
1663
|
// DELETE /sessions/<id> — idempotent. 200 on both "deleted" and
|
|
1614
1664
|
// "didn't exist" so callers can use it as a reset without checking
|
|
1615
|
-
// first.
|
|
1665
|
+
// first. m16: include `removed: <bool>` for shape parity with
|
|
1666
|
+
// sibling DELETEs (/skills, /workflows).
|
|
1616
1667
|
const id = sessionMatch[1];
|
|
1617
1668
|
try {
|
|
1618
|
-
|
|
1619
|
-
|
|
1669
|
+
// Use the sessions module's path resolver to check existence
|
|
1670
|
+
// BEFORE clearSession (which is unconditional unlink-if-exists).
|
|
1671
|
+
const sessDir = ctx.sessionsDirGetter();
|
|
1672
|
+
let existedBefore = false;
|
|
1673
|
+
try {
|
|
1674
|
+
const sessPath = ctx.sessionsMod.sessionPath
|
|
1675
|
+
? ctx.sessionsMod.sessionPath(id, sessDir)
|
|
1676
|
+
: null;
|
|
1677
|
+
if (sessPath) existedBefore = fs.existsSync(sessPath);
|
|
1678
|
+
} catch { /* sessionPath unavailable → leave as unknown */ }
|
|
1679
|
+
ctx.sessionsMod.clearSession(id, sessDir);
|
|
1680
|
+
return writeJson(res, 200, { ok: true, id, removed: existedBefore });
|
|
1620
1681
|
} catch (err) {
|
|
1621
1682
|
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
1622
1683
|
}
|
|
@@ -1949,14 +2010,29 @@ export function makeHandler(ctx) {
|
|
|
1949
2010
|
}
|
|
1950
2011
|
}
|
|
1951
2012
|
case req.method === 'GET' && /^\/agents\/([^/]+)\/memory$/.test(url.pathname): {
|
|
2013
|
+
// M13 — 404 when the agent is not registered. The historical
|
|
2014
|
+
// behaviour silently returned an empty body, which made
|
|
2015
|
+
// typos indistinguishable from "no memory yet" and let the
|
|
2016
|
+
// dashboard render a stub for a non-existent agent.
|
|
1952
2017
|
const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
|
|
2018
|
+
const agentsMod = await import('./agents.mjs');
|
|
2019
|
+
if (!agentsMod.getAgent(name)) {
|
|
2020
|
+
return writeJson(res, 404, { error: `no agent "${name}"`, name });
|
|
2021
|
+
}
|
|
1953
2022
|
const memMod = await import('./mas/agent_memory.mjs');
|
|
1954
2023
|
const text = memMod.readMemory(name);
|
|
1955
2024
|
res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-cache' });
|
|
1956
2025
|
return res.end(text);
|
|
1957
2026
|
}
|
|
1958
2027
|
case req.method === 'PUT' && /^\/agents\/([^/]+)\/memory$/.test(url.pathname): {
|
|
2028
|
+
// M13 — 404 when the agent is not registered. Without this
|
|
2029
|
+
// check, writeRaw happily created memory.md for a misspelled
|
|
2030
|
+
// agent name and the orphan file lived forever.
|
|
1959
2031
|
const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
|
|
2032
|
+
const agentsMod = await import('./agents.mjs');
|
|
2033
|
+
if (!agentsMod.getAgent(name)) {
|
|
2034
|
+
return writeJson(res, 404, { error: `no agent "${name}"`, name });
|
|
2035
|
+
}
|
|
1960
2036
|
const memMod = await import('./mas/agent_memory.mjs');
|
|
1961
2037
|
// Read raw text body — content-type defaults to text/markdown
|
|
1962
2038
|
// but JSON {"text": "..."} is also accepted for tooling that
|
|
@@ -1979,6 +2055,10 @@ export function makeHandler(ctx) {
|
|
|
1979
2055
|
}
|
|
1980
2056
|
case req.method === 'DELETE' && /^\/agents\/([^/]+)\/memory$/.test(url.pathname): {
|
|
1981
2057
|
const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
|
|
2058
|
+
const agentsMod = await import('./agents.mjs');
|
|
2059
|
+
if (!agentsMod.getAgent(name)) {
|
|
2060
|
+
return writeJson(res, 404, { error: `no agent "${name}"`, name });
|
|
2061
|
+
}
|
|
1982
2062
|
const memMod = await import('./mas/agent_memory.mjs');
|
|
1983
2063
|
const removed = memMod.clear(name);
|
|
1984
2064
|
return writeJson(res, 200, { name, cleared: removed });
|