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.
- package/README.ko.md +44 -0
- package/README.md +172 -508
- package/channels/handoff.mjs +41 -0
- package/channels/loader.mjs +124 -0
- package/channels/threads.mjs +116 -0
- package/cli.mjs +430 -7
- package/daemon.mjs +13 -0
- package/mas/agent_turn.mjs +28 -0
- package/mas/confidence.mjs +108 -0
- package/mas/index_db.mjs +242 -0
- package/mas/nudge.mjs +97 -0
- package/mas/prompt_stack.mjs +80 -0
- package/mas/skill_synth.mjs +124 -25
- package/mas/tool_runner.mjs +10 -61
- package/mas/tools/browser.mjs +77 -0
- package/mas/tools/clarify.mjs +36 -0
- package/mas/tools/coding.mjs +109 -0
- package/mas/tools/delegation.mjs +53 -0
- package/mas/tools/edit.mjs +36 -0
- package/mas/tools/git.mjs +110 -0
- package/mas/tools/ha.mjs +34 -0
- package/mas/tools/learning.mjs +168 -0
- package/mas/tools/media.mjs +105 -0
- package/mas/tools/os.mjs +152 -0
- package/mas/tools/patch.mjs +91 -0
- package/mas/tools/recall.mjs +103 -0
- package/mas/tools/registry.mjs +93 -0
- package/mas/tools/scheduling.mjs +62 -0
- package/mas/tools/web.mjs +137 -0
- package/mas/toolsets.mjs +64 -0
- package/mas/trajectory_export.mjs +169 -0
- package/mas/trajectory_store.mjs +179 -0
- package/mas/user_modeler.mjs +108 -0
- package/package.json +20 -3
- package/providers/codex_cli.mjs +200 -0
- package/providers/gemini_cli.mjs +179 -0
- package/providers/registry.mjs +61 -1
- package/sandbox/base.mjs +82 -0
- package/sandbox/confiners/bubblewrap.mjs +21 -0
- package/sandbox/confiners/firejail.mjs +16 -0
- package/sandbox/confiners/landlock.mjs +14 -0
- package/sandbox/confiners/seatbelt.mjs +28 -0
- package/sandbox/daytona.mjs +37 -0
- package/sandbox/docker.mjs +91 -0
- package/sandbox/index.mjs +67 -0
- package/sandbox/local.mjs +59 -0
- package/sandbox/modal.mjs +53 -0
- package/sandbox/singularity.mjs +39 -0
- package/sandbox/ssh.mjs +56 -0
- package/sandbox.mjs +11 -127
- package/scripts/hermes-import.mjs +111 -0
- package/scripts/migrate-v5.mjs +342 -0
- package/scripts/openclaw-import.mjs +71 -0
- 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
|
+
}
|
package/mas/index_db.mjs
ADDED
|
@@ -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
|
+
}
|