lazyclaw 4.3.0 → 5.0.1

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 (54) hide show
  1. package/README.ko.md +44 -0
  2. package/README.md +172 -508
  3. package/channels/handoff.mjs +41 -0
  4. package/channels/loader.mjs +124 -0
  5. package/channels/threads.mjs +116 -0
  6. package/cli.mjs +430 -7
  7. package/daemon.mjs +13 -0
  8. package/mas/agent_turn.mjs +28 -0
  9. package/mas/confidence.mjs +108 -0
  10. package/mas/index_db.mjs +242 -0
  11. package/mas/nudge.mjs +97 -0
  12. package/mas/prompt_stack.mjs +80 -0
  13. package/mas/skill_synth.mjs +124 -25
  14. package/mas/tool_runner.mjs +10 -61
  15. package/mas/tools/browser.mjs +77 -0
  16. package/mas/tools/clarify.mjs +36 -0
  17. package/mas/tools/coding.mjs +109 -0
  18. package/mas/tools/delegation.mjs +53 -0
  19. package/mas/tools/edit.mjs +36 -0
  20. package/mas/tools/git.mjs +110 -0
  21. package/mas/tools/ha.mjs +34 -0
  22. package/mas/tools/learning.mjs +168 -0
  23. package/mas/tools/media.mjs +105 -0
  24. package/mas/tools/os.mjs +152 -0
  25. package/mas/tools/patch.mjs +91 -0
  26. package/mas/tools/recall.mjs +103 -0
  27. package/mas/tools/registry.mjs +93 -0
  28. package/mas/tools/scheduling.mjs +62 -0
  29. package/mas/tools/web.mjs +137 -0
  30. package/mas/toolsets.mjs +64 -0
  31. package/mas/trajectory_export.mjs +169 -0
  32. package/mas/trajectory_store.mjs +179 -0
  33. package/mas/user_modeler.mjs +108 -0
  34. package/package.json +20 -3
  35. package/providers/codex_cli.mjs +200 -0
  36. package/providers/gemini_cli.mjs +179 -0
  37. package/providers/registry.mjs +61 -1
  38. package/sandbox/base.mjs +82 -0
  39. package/sandbox/confiners/bubblewrap.mjs +21 -0
  40. package/sandbox/confiners/firejail.mjs +16 -0
  41. package/sandbox/confiners/landlock.mjs +14 -0
  42. package/sandbox/confiners/seatbelt.mjs +28 -0
  43. package/sandbox/daytona.mjs +37 -0
  44. package/sandbox/docker.mjs +91 -0
  45. package/sandbox/index.mjs +67 -0
  46. package/sandbox/local.mjs +59 -0
  47. package/sandbox/modal.mjs +53 -0
  48. package/sandbox/singularity.mjs +39 -0
  49. package/sandbox/ssh.mjs +56 -0
  50. package/sandbox.mjs +11 -127
  51. package/scripts/hermes-import.mjs +111 -0
  52. package/scripts/migrate-v5.mjs +342 -0
  53. package/scripts/openclaw-import.mjs +71 -0
  54. package/sessions.mjs +20 -1
@@ -0,0 +1,108 @@
1
+ // Confidence calculator for v5 skills — spec §0.1 H2, §3.5.
2
+ //
3
+ // Pure functions, no I/O. Used by skill_synth v2 to stamp frontmatter
4
+ // and by trajectory_store recall ranking to weight near-duplicates.
5
+ //
6
+ // wilsonLowerBound(s, n) — 95% Wilson lower bound on success rate.
7
+ // crossCliDampen(score, trainer, provider, factor?)
8
+ // — multiply by `factor` (default 0.85)
9
+ // when trainer provider differs from
10
+ // worker provider (canonical decision
11
+ // §0.1 H2). Phase H2 makes the factor
12
+ // tunable; same-family scores are
13
+ // always returned unchanged.
14
+ // recencyDecay(ageMs, halfLifeMs) — exponential decay weight (0..1].
15
+ // computeConfidence({successes, trials, ageMs, trainer, provider, dampenFactor?})
16
+ // — composed score in [0, 1].
17
+ // resolveDampenFactor(cfg) — pull `crossCliDampenFactor` out of
18
+ // cfg.orchestra.learning (or legacy
19
+ // cfg.orchestrator.learning); clamps
20
+ // to [0,1]; defaults to 0.85 on miss.
21
+
22
+ const Z = 1.96; // 95% confidence two-sided
23
+
24
+ export const DEFAULT_CROSS_CLI_DAMPEN = 0.85;
25
+
26
+ export function wilsonLowerBound(successes, trials) {
27
+ const s = Number(successes) || 0;
28
+ const n = Number(trials) || 0;
29
+ if (n <= 0) return 0;
30
+ const phat = s / n;
31
+ const z2 = Z * Z;
32
+ const denom = 1 + z2 / n;
33
+ const center = phat + z2 / (2 * n);
34
+ const margin = Z * Math.sqrt((phat * (1 - phat) + z2 / (4 * n)) / n);
35
+ const lb = (center - margin) / denom;
36
+ return Math.max(0, Math.min(1, lb));
37
+ }
38
+
39
+ const PROVIDER_FAMILY = {
40
+ 'claude-cli': 'anthropic',
41
+ 'anthropic': 'anthropic',
42
+ 'codex-cli': 'openai',
43
+ 'openai': 'openai',
44
+ 'gemini-cli': 'gemini',
45
+ 'gemini': 'gemini',
46
+ 'ollama': 'ollama',
47
+ };
48
+
49
+ export function sameFamily(a, b) {
50
+ if (!a || !b) return false;
51
+ return PROVIDER_FAMILY[a] === PROVIDER_FAMILY[b];
52
+ }
53
+
54
+ // Normalise a caller-supplied dampening factor. Anything non-finite (NaN,
55
+ // non-numeric strings, undefined) falls back to the canonical 0.85 so a
56
+ // typo in config does not silently zero out every cross-CLI skill.
57
+ function _normalizeFactor(factor) {
58
+ if (factor === null || factor === undefined) return DEFAULT_CROSS_CLI_DAMPEN;
59
+ const n = Number(factor);
60
+ if (!Number.isFinite(n)) return DEFAULT_CROSS_CLI_DAMPEN;
61
+ if (n < 0) return 0;
62
+ if (n > 1) return 1;
63
+ return n;
64
+ }
65
+
66
+ export function crossCliDampen(score, trainerProvider, workerProvider, factor) {
67
+ if (!trainerProvider || !workerProvider) return score;
68
+ if (sameFamily(trainerProvider, workerProvider)) return score;
69
+ return score * _normalizeFactor(factor);
70
+ }
71
+
72
+ export function recencyDecay(ageMs, halfLifeMs = 30 * 24 * 60 * 60 * 1000) {
73
+ const t = Math.max(0, Number(ageMs) || 0);
74
+ const hl = Math.max(1, Number(halfLifeMs) || 1);
75
+ return Math.pow(0.5, t / hl);
76
+ }
77
+
78
+ export function computeConfidence({
79
+ successes = 0,
80
+ trials = 0,
81
+ ageMs = 0,
82
+ trainerProvider = null,
83
+ workerProvider = null,
84
+ halfLifeMs,
85
+ dampenFactor,
86
+ } = {}) {
87
+ const base = wilsonLowerBound(successes, trials);
88
+ const decayed = base * recencyDecay(ageMs, halfLifeMs);
89
+ const dampened = crossCliDampen(decayed, trainerProvider, workerProvider, dampenFactor);
90
+ return Math.max(0, Math.min(1, dampened));
91
+ }
92
+
93
+ // Pull the tunable factor out of config. Honours both the canonical
94
+ // `cfg.orchestra.learning.crossCliDampenFactor` shape and the legacy
95
+ // `cfg.orchestrator.learning.crossCliDampenFactor` (v4 callers). Returns
96
+ // 0.85 when missing/invalid so callers never have to gate on undefined.
97
+ export function resolveDampenFactor(cfg) {
98
+ if (!cfg || typeof cfg !== 'object') return DEFAULT_CROSS_CLI_DAMPEN;
99
+ const orchestra = cfg.orchestra || cfg.orchestrator || {};
100
+ const learning = orchestra.learning || {};
101
+ const raw = learning.crossCliDampenFactor;
102
+ if (raw === undefined || raw === null) return DEFAULT_CROSS_CLI_DAMPEN;
103
+ const n = Number(raw);
104
+ if (!Number.isFinite(n)) return DEFAULT_CROSS_CLI_DAMPEN;
105
+ if (n < 0) return 0;
106
+ if (n > 1) return 1;
107
+ return n;
108
+ }
@@ -0,0 +1,242 @@
1
+ // mas/index_db.mjs — Phase A.
2
+ //
3
+ // Single SQLite handle per configDir backing four FTS5 virtual tables
4
+ // (spec §4.3). The daemon opens the db at boot; CLI subcommands open
5
+ // on demand. WAL mode lets many readers coexist with the one writer.
6
+ //
7
+ // Index failure NEVER propagates — see spec §4.4: write-through hooks
8
+ // log and swallow so a corrupt index can't break the session-write
9
+ // path. Recovery is via `lazyclaw index rebuild`.
10
+
11
+ import Database from 'better-sqlite3';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+ import { redactSecrets } from './redact.mjs';
16
+
17
+ const SCHEMA_VERSION = 1;
18
+ const _handles = new Map(); // configDir → { db, stmts }
19
+
20
+ function defaultConfigDir() {
21
+ return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
22
+ }
23
+
24
+ function dbPath(configDir) {
25
+ return path.join(configDir, 'index.db');
26
+ }
27
+
28
+ function ensureSchema(db) {
29
+ db.exec(`
30
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_sessions USING fts5(
31
+ content,
32
+ session_id UNINDEXED, turn_idx UNINDEXED, role UNINDEXED, ts UNINDEXED
33
+ );
34
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_skills USING fts5(
35
+ content,
36
+ skill_name UNINDEXED, trained_by UNINDEXED, group_name UNINDEXED
37
+ );
38
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_trajectories USING fts5(
39
+ content,
40
+ trajectory_id UNINDEXED, agent UNINDEXED, outcome UNINDEXED
41
+ );
42
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_memories USING fts5(
43
+ content,
44
+ topic UNINDEXED, kind UNINDEXED
45
+ );
46
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
47
+ `);
48
+ const cur = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get();
49
+ if (!cur) {
50
+ db.prepare("INSERT INTO meta(key,value) VALUES('schema_version', ?)").run(String(SCHEMA_VERSION));
51
+ }
52
+ }
53
+
54
+ function prepareStatements(db) {
55
+ return {
56
+ insertSession: db.prepare(
57
+ `INSERT INTO fts_sessions(content, session_id, turn_idx, role, ts)
58
+ VALUES (?, ?, ?, ?, ?)`),
59
+ insertSkill: db.prepare(
60
+ `INSERT INTO fts_skills(content, skill_name, trained_by, group_name)
61
+ VALUES (?, ?, ?, ?)`),
62
+ insertTrajectory: db.prepare(
63
+ `INSERT INTO fts_trajectories(content, trajectory_id, agent, outcome)
64
+ VALUES (?, ?, ?, ?)`),
65
+ insertMemory: db.prepare(
66
+ `INSERT INTO fts_memories(content, topic, kind)
67
+ VALUES (?, ?, ?)`),
68
+ queries: {
69
+ sessions: db.prepare(
70
+ `SELECT 'sessions' AS scope, bm25(fts_sessions) AS bm25,
71
+ snippet(fts_sessions, 0, '<mark>', '</mark>', '...', 16) AS snippet,
72
+ session_id, turn_idx, role, ts
73
+ FROM fts_sessions WHERE content MATCH ? ORDER BY bm25 LIMIT ?`),
74
+ skills: db.prepare(
75
+ `SELECT 'skills' AS scope, bm25(fts_skills) AS bm25,
76
+ snippet(fts_skills, 0, '<mark>', '</mark>', '...', 16) AS snippet,
77
+ skill_name, trained_by, group_name
78
+ FROM fts_skills WHERE content MATCH ? ORDER BY bm25 LIMIT ?`),
79
+ trajectories: db.prepare(
80
+ `SELECT 'trajectories' AS scope, bm25(fts_trajectories) AS bm25,
81
+ snippet(fts_trajectories, 0, '<mark>', '</mark>', '...', 16) AS snippet,
82
+ trajectory_id, agent, outcome
83
+ FROM fts_trajectories WHERE content MATCH ? ORDER BY bm25 LIMIT ?`),
84
+ memories: db.prepare(
85
+ `SELECT 'memories' AS scope, bm25(fts_memories) AS bm25,
86
+ snippet(fts_memories, 0, '<mark>', '</mark>', '...', 16) AS snippet,
87
+ topic, kind
88
+ FROM fts_memories WHERE content MATCH ? ORDER BY bm25 LIMIT ?`),
89
+ },
90
+ };
91
+ }
92
+
93
+ export function openIndex(configDir = defaultConfigDir(), opts = {}) {
94
+ const dir = configDir;
95
+ if (_handles.has(dir)) return _handles.get(dir).db;
96
+ fs.mkdirSync(dir, { recursive: true });
97
+ const db = new Database(dbPath(dir));
98
+ db.pragma('journal_mode = WAL');
99
+ db.pragma('synchronous = NORMAL');
100
+ ensureSchema(db);
101
+ if (opts.runIntegrityCheck !== false) {
102
+ const r = db.pragma('integrity_check', { simple: true });
103
+ if (r !== 'ok') {
104
+ // eslint-disable-next-line no-console
105
+ console.warn(`[index_db] integrity_check returned ${r} for ${dbPath(dir)}`);
106
+ }
107
+ }
108
+ const stmts = prepareStatements(db);
109
+ _handles.set(dir, { db, stmts });
110
+ return db;
111
+ }
112
+
113
+ export function closeIndex(configDir = defaultConfigDir()) {
114
+ const h = _handles.get(configDir);
115
+ if (!h) return;
116
+ try { h.db.close(); } catch { /* ignore */ }
117
+ _handles.delete(configDir);
118
+ }
119
+
120
+ function _stmts(configDir) {
121
+ if (!_handles.has(configDir)) openIndex(configDir);
122
+ return _handles.get(configDir).stmts;
123
+ }
124
+
125
+ export function indexSessionTurn(row, configDir = defaultConfigDir()) {
126
+ try {
127
+ const s = _stmts(configDir);
128
+ s.insertSession.run(
129
+ redactSecrets(String(row.content || '')),
130
+ String(row.session_id || ''), Number(row.turn_idx || 0),
131
+ String(row.role || ''), Number(row.ts || Date.now()),
132
+ );
133
+ } catch (e) {
134
+ // eslint-disable-next-line no-console
135
+ console.warn('[index_db] indexSessionTurn failed:', e.message);
136
+ }
137
+ }
138
+
139
+ export function indexSkill(row, configDir = defaultConfigDir()) {
140
+ try {
141
+ const s = _stmts(configDir);
142
+ s.insertSkill.run(
143
+ redactSecrets(String(row.content || '')),
144
+ String(row.skill_name || ''), String(row.trained_by || ''),
145
+ String(row.group_name || ''),
146
+ );
147
+ } catch (e) {
148
+ // eslint-disable-next-line no-console
149
+ console.warn('[index_db] indexSkill failed:', e.message);
150
+ }
151
+ }
152
+
153
+ export function indexTrajectory(row, configDir = defaultConfigDir()) {
154
+ try {
155
+ const s = _stmts(configDir);
156
+ s.insertTrajectory.run(
157
+ redactSecrets(String(row.content || '')),
158
+ String(row.trajectory_id || ''), String(row.agent || ''),
159
+ String(row.outcome || ''),
160
+ );
161
+ } catch (e) {
162
+ // eslint-disable-next-line no-console
163
+ console.warn('[index_db] indexTrajectory failed:', e.message);
164
+ }
165
+ }
166
+
167
+ export function indexMemory(row, configDir = defaultConfigDir()) {
168
+ try {
169
+ const s = _stmts(configDir);
170
+ s.insertMemory.run(
171
+ redactSecrets(String(row.content || '')),
172
+ String(row.topic || ''), String(row.kind || ''),
173
+ );
174
+ } catch (e) {
175
+ // eslint-disable-next-line no-console
176
+ console.warn('[index_db] indexMemory failed:', e.message);
177
+ }
178
+ }
179
+
180
+ // FTS5 query parser treats `-` as unary NOT and `:` as a column filter, so
181
+ // raw user phrases like "write-through" turn into a NOT op against a
182
+ // non-existent column. Strip FTS5 operators down to whitespace before the
183
+ // MATCH so a bareword recall behaves like content search. Quoted phrases
184
+ // from the caller are left alone (one or more `"` survives).
185
+ function sanitizeFtsQuery(q) {
186
+ const s = String(q ?? '').trim();
187
+ if (!s) return s;
188
+ // Preserve quoted phrases verbatim; rewrite only unquoted runs.
189
+ if (/["()*^]/.test(s)) return s;
190
+ return s.replace(/[-:+]/g, ' ').replace(/\s+/g, ' ').trim();
191
+ }
192
+
193
+ export function recall(query, opts = {}) {
194
+ const t0 = process.hrtime.bigint();
195
+ const configDir = opts.configDir || defaultConfigDir();
196
+ const scope = opts.scope || ['sessions', 'skills', 'trajectories', 'memories'];
197
+ const k = Math.min(Math.max(Number(opts.k) || 10, 1), 50);
198
+ const s = _stmts(configDir);
199
+ const safeQuery = sanitizeFtsQuery(query);
200
+ const hits = [];
201
+ for (const sc of scope) {
202
+ const stmt = s.queries[sc];
203
+ if (!stmt) continue;
204
+ try {
205
+ const rows = stmt.all(safeQuery, k);
206
+ for (const r of rows) {
207
+ const { scope: sc2, bm25, snippet, ...metadata } = r;
208
+ hits.push({ scope: sc2, rank: hits.length, bm25, snippet, metadata });
209
+ }
210
+ } catch (e) {
211
+ // FTS5 MATCH syntax errors are caller mistakes; skip silently.
212
+ // "no such column" arises from the FTS5 column-filter syntax when
213
+ // a stray colon/operator survives sanitisation — also benign.
214
+ if (!/syntax error|no such column/i.test(e.message)) throw e;
215
+ }
216
+ }
217
+ hits.sort((a, b) => a.bm25 - b.bm25);
218
+ const trimmed = hits.slice(0, k);
219
+ for (let i = 0; i < trimmed.length; i++) trimmed[i].rank = i;
220
+ const elapsedNs = process.hrtime.bigint() - t0;
221
+ return { query, hits: trimmed, latencyMs: Number(elapsedNs) / 1e6 };
222
+ }
223
+
224
+ export function integrityCheck(configDir = defaultConfigDir()) {
225
+ const db = openIndex(configDir);
226
+ const r = db.pragma('integrity_check', { simple: true });
227
+ return { ok: r === 'ok', result: r };
228
+ }
229
+
230
+ export function rebuild(configDir = defaultConfigDir()) {
231
+ closeIndex(configDir);
232
+ const p = dbPath(configDir);
233
+ if (fs.existsSync(p)) {
234
+ fs.unlinkSync(p);
235
+ // WAL sidecar files
236
+ for (const ext of ['-wal', '-shm']) {
237
+ const side = p + ext;
238
+ if (fs.existsSync(side)) fs.unlinkSync(side);
239
+ }
240
+ }
241
+ openIndex(configDir);
242
+ }
package/mas/nudge.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // Nudge loop — Phase B (v5 §3.6).
2
+ //
3
+ // Periodically scans the tail of memory/recent.jsonl, clusters
4
+ // repeated user prompts, and (when a cluster crosses minCount) emits
5
+ // a `nudge.suggest_skill` event into the daemon's SSE bus so the
6
+ // curator UI can suggest "should I turn this into a skill?".
7
+ //
8
+ // v5.0 scope: emit only. Cross-channel push (Slack/Telegram) lands in
9
+ // v5.1 per spec §0.2.
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import os from 'node:os';
14
+
15
+ const DEFAULT_TAIL = 200;
16
+ const DEFAULT_INTERVAL_MS = 5 * 60 * 1000; // 5 min
17
+ const DEFAULT_MIN_COUNT = 3;
18
+
19
+ export function defaultConfigDir() {
20
+ return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
21
+ }
22
+
23
+ function recentPath(configDir) {
24
+ return path.join(configDir, 'memory', 'recent.jsonl');
25
+ }
26
+
27
+ export function readRecent(configDir = defaultConfigDir(), n = DEFAULT_TAIL) {
28
+ const p = recentPath(configDir);
29
+ let raw;
30
+ try { raw = fs.readFileSync(p, 'utf8'); } catch { return []; }
31
+ const lines = raw.split('\n').filter(Boolean).slice(-n);
32
+ const out = [];
33
+ for (const line of lines) {
34
+ try { out.push(JSON.parse(line)); } catch { /* skip */ }
35
+ }
36
+ return out;
37
+ }
38
+
39
+ function normalise(text) {
40
+ return String(text || '')
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9\s]+/g, ' ')
43
+ .replace(/\s+/g, ' ')
44
+ .trim();
45
+ }
46
+
47
+ export function clusterRecent(entries, { minCount = DEFAULT_MIN_COUNT } = {}) {
48
+ const byKey = new Map();
49
+ for (const e of entries || []) {
50
+ if (e.role && e.role !== 'user') continue;
51
+ const key = normalise(e.content);
52
+ if (!key) continue;
53
+ if (!byKey.has(key)) byKey.set(key, { key, count: 0, sample: e.content, firstTs: e.ts, lastTs: e.ts });
54
+ const c = byKey.get(key);
55
+ c.count += 1;
56
+ c.lastTs = e.ts;
57
+ }
58
+ return [...byKey.values()].filter((c) => c.count >= minCount).sort((a, b) => b.count - a.count);
59
+ }
60
+
61
+ export function makeNudgeEvent({ cluster, ts = Date.now() } = {}) {
62
+ return {
63
+ kind: 'nudge.suggest_skill',
64
+ ts,
65
+ cluster: {
66
+ count: cluster.count,
67
+ sample: cluster.sample,
68
+ firstTs: cluster.firstTs,
69
+ lastTs: cluster.lastTs,
70
+ },
71
+ suggestion: `Repeated prompt: "${String(cluster.sample).slice(0, 80)}" (${cluster.count}×). Consider /skill create.`,
72
+ };
73
+ }
74
+
75
+ export function startNudgeLoop({ configDir = defaultConfigDir(), intervalMs = DEFAULT_INTERVAL_MS, minCount = DEFAULT_MIN_COUNT, emit, logger } = {}) {
76
+ if (typeof emit !== 'function') throw new Error('startNudgeLoop: emit(event) is required');
77
+ let timer = null;
78
+ let stopped = false;
79
+
80
+ function tick() {
81
+ if (stopped) return;
82
+ try {
83
+ const entries = readRecent(configDir);
84
+ const clusters = clusterRecent(entries, { minCount });
85
+ for (const c of clusters) emit(makeNudgeEvent({ cluster: c }));
86
+ } catch (err) {
87
+ try { logger?.warn?.('nudge_tick_failed', { err: err.message }); } catch { /* ignore */ }
88
+ }
89
+ }
90
+
91
+ timer = setInterval(tick, intervalMs);
92
+ if (typeof timer.unref === 'function') timer.unref();
93
+ return {
94
+ stop() { stopped = true; if (timer) clearInterval(timer); timer = null; },
95
+ runOnce: tick,
96
+ };
97
+ }
@@ -0,0 +1,80 @@
1
+ // mas/prompt_stack.mjs
2
+ // 8-layer system-prompt composer (v5.0 spec §9.3, canonical C10).
3
+ // Layers (top-to-bottom in the system prompt):
4
+ // 1. Global SOUL.md <configDir>/SOUL.md
5
+ // 1.5 Workspace SOUL.md <configDir>/workspaces/<name>/SOUL.md (C10)
6
+ // 2. Personality <configDir>/personalities/<name>.md (C7)
7
+ // 3. agent.role from agent record
8
+ // 4. USER.md <configDir>/memory/USER.md (C6)
9
+ // 5. Skill index skills.skillsIndex(cfgDir)
10
+ // 6. Memory (core.md) memory.loadCore(cfgDir)
11
+ // 7. Trajectory tail last recent.jsonl entry (best-effort)
12
+ //
13
+ // Missing layers are silently skipped. Never throws. Result is a single
14
+ // newline-joined string suitable for prepending to the agent system
15
+ // prompt. Caller decides whether to further sandwich it with task input.
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { skillsIndex } from '../skills.mjs';
20
+ import { loadCore, recentPath, defaultConfigDir } from '../memory.mjs';
21
+
22
+ function readOpt(p) {
23
+ try { return fs.readFileSync(p, 'utf8').trim(); }
24
+ catch { return ''; }
25
+ }
26
+
27
+ function lastRecentLine(cfgDir) {
28
+ try {
29
+ const p = recentPath(cfgDir);
30
+ if (!fs.existsSync(p)) return '';
31
+ const txt = fs.readFileSync(p, 'utf8');
32
+ const lines = txt.split('\n').filter(Boolean);
33
+ if (!lines.length) return '';
34
+ const parsed = JSON.parse(lines[lines.length - 1]);
35
+ return `${parsed.role || 'user'}: ${String(parsed.content || '').slice(0, 240)}`;
36
+ } catch { return ''; }
37
+ }
38
+
39
+ export function composePromptStack({ cfgDir, agent, workspace, sessionId } = {}) {
40
+ const dir = cfgDir || defaultConfigDir();
41
+ const a = agent || {};
42
+ const parts = [];
43
+
44
+ // 1. global SOUL
45
+ const globalSoul = readOpt(path.join(dir, 'SOUL.md'));
46
+ if (globalSoul) parts.push(`## SOUL\n${globalSoul}`);
47
+
48
+ // 1.5 workspace SOUL (C10)
49
+ if (workspace) {
50
+ const wsSoul = readOpt(path.join(dir, 'workspaces', workspace, 'SOUL.md'));
51
+ if (wsSoul) parts.push(`## Workspace SOUL (${workspace})\n${wsSoul}`);
52
+ }
53
+
54
+ // 2. personality (C7)
55
+ if (a.personality) {
56
+ const p = readOpt(path.join(dir, 'personalities', `${a.personality}.md`));
57
+ if (p) parts.push(`## Personality (${a.personality})\n${p}`);
58
+ }
59
+
60
+ // 3. agent.role
61
+ if (a.role) parts.push(`## Role (${a.name || 'agent'})\n${a.role}`);
62
+
63
+ // 4. USER.md (C6)
64
+ const userMd = readOpt(path.join(dir, 'memory', 'USER.md'));
65
+ if (userMd) parts.push(`## What the user has told you before\n${userMd}`);
66
+
67
+ // 5. skill index
68
+ const idx = skillsIndex(dir);
69
+ if (idx) parts.push(`## Available skills\n${idx}`);
70
+
71
+ // 6. memory core.md
72
+ const core = loadCore(dir);
73
+ if (core && core.trim()) parts.push(`## Long-term memory\n${core.trim()}`);
74
+
75
+ // 7. trajectory tail (sessionId may be ignored — recent.jsonl is global)
76
+ const tail = lastRecentLine(dir);
77
+ if (tail) parts.push(`## Most-recent turn\n${tail}`);
78
+
79
+ return parts.join('\n\n');
80
+ }