lazyclaw 4.2.2 → 5.0.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/README.ko.md +44 -0
- package/README.md +172 -353
- package/agents.mjs +19 -3
- package/channels/handoff.mjs +41 -0
- package/channels/loader.mjs +124 -0
- package/channels/matrix.mjs +417 -0
- package/channels/telegram.mjs +362 -0
- package/channels/threads.mjs +116 -0
- package/cli.mjs +730 -27
- package/daemon.mjs +111 -0
- package/gateway/device_auth.mjs +664 -0
- package/gateway/http_gateway.mjs +304 -0
- package/mas/agent_memory.mjs +35 -34
- package/mas/agent_turn.mjs +30 -1
- package/mas/confidence.mjs +108 -0
- package/mas/index_db.mjs +242 -0
- package/mas/mention_router.mjs +75 -4
- package/mas/nudge.mjs +97 -0
- package/mas/prompt_stack.mjs +80 -0
- package/mas/provider_adapters.mjs +83 -0
- package/mas/redact.mjs +46 -0
- package/mas/skill_synth.mjs +331 -0
- package/mas/tool_runner.mjs +19 -48
- 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/skill_view.mjs +43 -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 +22 -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
- package/skills.mjs +101 -8
- package/skills_curator.mjs +323 -0
- package/workspace.mjs +18 -3
package/mas/toolsets.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// toolsets — named bundles of tool names that an agent can be assigned via
|
|
2
|
+
// `lazyclaw agent edit <name> --toolset coding-min`. Built-ins ship in
|
|
3
|
+
// code; user-defined sets live in <configDir>/toolsets.json.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const BUILTIN = {
|
|
9
|
+
'coding-min': { tools: ['bash', 'read', 'write', 'edit', 'patch', 'grep', 'git_status', 'git_diff'] },
|
|
10
|
+
'web-research':{ tools: ['web_fetch', 'web_search', 'url_extract', 'read', 'write', 'recall'] },
|
|
11
|
+
'devops': { tools: ['bash', 'git_status', 'git_diff', 'git_log', 'git_commit', 'cron_add', 'cron_list', 'http_request'] },
|
|
12
|
+
'learning': { tools: ['recall', 'skill_view', 'skill_create', 'skill_edit', 'memory_read', 'memory_write', 'user_view', 'user_update'] },
|
|
13
|
+
'media': { tools: ['image_describe', 'image_generate', 'transcribe'] },
|
|
14
|
+
'agentic': { tools: ['task_spawn', 'delegate', 'clarify', 'recall', 'skill_view'] },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function configFile(opts) {
|
|
18
|
+
const dir = opts?.configDir || process.env.LAZYCLAW_CONFIG_DIR || path.join(process.env.HOME || '.', '.lazyclaw');
|
|
19
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
20
|
+
return path.join(dir, 'toolsets.json');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readUser(opts) {
|
|
24
|
+
const f = configFile(opts);
|
|
25
|
+
if (!fs.existsSync(f)) return {};
|
|
26
|
+
try { return JSON.parse(fs.readFileSync(f, 'utf8')); }
|
|
27
|
+
catch { return {}; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeUser(data, opts) {
|
|
31
|
+
fs.writeFileSync(configFile(opts), JSON.stringify(data, null, 2));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function listToolsets(opts) {
|
|
35
|
+
const user = readUser(opts);
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const [name, t] of Object.entries(BUILTIN)) out.push({ name, ...t, source: 'builtin' });
|
|
38
|
+
for (const [name, t] of Object.entries(user)) out.push({ name, ...t, source: 'user' });
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveToolset(name, opts) {
|
|
43
|
+
const user = readUser(opts);
|
|
44
|
+
const t = user[name] || BUILTIN[name];
|
|
45
|
+
if (!t || !Array.isArray(t.tools)) throw new Error(`toolset "${name}" not found`);
|
|
46
|
+
return [...t.tools];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function addToolset({ name, tools }, opts) {
|
|
50
|
+
if (!name || !Array.isArray(tools)) throw new Error('addToolset: name + tools[] required');
|
|
51
|
+
if (BUILTIN[name]) throw new Error(`toolset "${name}" is built-in; pick a different name`);
|
|
52
|
+
const data = readUser(opts);
|
|
53
|
+
data[name] = { tools };
|
|
54
|
+
writeUser(data, opts);
|
|
55
|
+
return data[name];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function removeToolset(name, opts) {
|
|
59
|
+
if (BUILTIN[name]) throw new Error(`cannot remove built-in toolset "${name}"`);
|
|
60
|
+
const data = readUser(opts);
|
|
61
|
+
delete data[name];
|
|
62
|
+
writeUser(data, opts);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// mas/trajectory_export.mjs — Phase H1.
|
|
2
|
+
//
|
|
3
|
+
// Trajectory exporter (spec §2.7). Read-only serialiser — never spawns
|
|
4
|
+
// a trainer, never touches weights. Reads JSONL records produced by
|
|
5
|
+
// mas/trajectory_store.mjs and emits one of four downstream formats:
|
|
6
|
+
//
|
|
7
|
+
// jsonl — raw transcripts (record verbatim, one per line)
|
|
8
|
+
// atropos — NousResearch/atropos: {messages, reward, metadata}
|
|
9
|
+
// axolotl — Axolotl ShareGPT: {conversations: [{from, value}]}
|
|
10
|
+
// openai-ft — OpenAI fine-tune: {messages: [{role, content}]}
|
|
11
|
+
//
|
|
12
|
+
// Filters: --since <Nd|Nh|Nm> (relative window), --outcome <done|failed|abandoned>.
|
|
13
|
+
// `reward` for atropos is null by default per spec Appendix B.6 #22
|
|
14
|
+
// (reward signal undefined for v5.0 — `--reward none` is the default).
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
|
|
20
|
+
export const FORMATS = Object.freeze(['atropos', 'axolotl', 'openai-ft', 'jsonl']);
|
|
21
|
+
|
|
22
|
+
function defaultConfigDir() {
|
|
23
|
+
return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function trajectoriesDir(configDir) {
|
|
27
|
+
return path.join(configDir, 'trajectories');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Parse "7d" / "12h" / "30m" into a millisecond window relative to now.
|
|
31
|
+
// Returns null if input is empty/undefined (no filter).
|
|
32
|
+
function parseSince(spec) {
|
|
33
|
+
if (!spec) return null;
|
|
34
|
+
const m = String(spec).match(/^(\d+)\s*([dhm])$/i);
|
|
35
|
+
if (!m) throw new Error(`invalid --since: ${spec} (expected Nd|Nh|Nm)`);
|
|
36
|
+
const n = parseInt(m[1], 10);
|
|
37
|
+
const unit = m[2].toLowerCase();
|
|
38
|
+
const mult = unit === 'd' ? 86400_000 : unit === 'h' ? 3600_000 : 60_000;
|
|
39
|
+
return Date.now() - n * mult;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function* iterRecords(configDir) {
|
|
43
|
+
const root = trajectoriesDir(configDir);
|
|
44
|
+
if (!fs.existsSync(root)) return;
|
|
45
|
+
const buckets = fs.readdirSync(root).sort();
|
|
46
|
+
for (const bucket of buckets) {
|
|
47
|
+
const bdir = path.join(root, bucket);
|
|
48
|
+
let stat; try { stat = fs.statSync(bdir); } catch { continue; }
|
|
49
|
+
if (!stat.isDirectory()) continue;
|
|
50
|
+
for (const f of fs.readdirSync(bdir).sort()) {
|
|
51
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
52
|
+
let raw;
|
|
53
|
+
try { raw = fs.readFileSync(path.join(bdir, f), 'utf8'); } catch { continue; }
|
|
54
|
+
for (const line of raw.split('\n')) {
|
|
55
|
+
const s = line.trim();
|
|
56
|
+
if (!s) continue;
|
|
57
|
+
try { yield JSON.parse(s); } catch { /* skip corrupt */ }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Build a uniform message list from a TrajectoryRecord. The store's
|
|
64
|
+
// canonical shape carries systemPrompt + userMessages + turns; downstream
|
|
65
|
+
// formats all want a chronological role+content sequence so we synthesise
|
|
66
|
+
// one here.
|
|
67
|
+
function toMessages(rec) {
|
|
68
|
+
const out = [];
|
|
69
|
+
if (rec.systemPrompt) out.push({ role: 'system', content: String(rec.systemPrompt) });
|
|
70
|
+
for (const u of rec.userMessages || []) {
|
|
71
|
+
out.push({ role: 'user', content: String(u) });
|
|
72
|
+
}
|
|
73
|
+
for (const t of rec.turns || []) {
|
|
74
|
+
if (!t || !t.role) continue;
|
|
75
|
+
// turns may already include the user echo; keep them — downstream
|
|
76
|
+
// trainers expect duplicates filtered at their own dedupe stage.
|
|
77
|
+
out.push({ role: t.role, content: String(t.content || '') });
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatAtropos(rec) {
|
|
83
|
+
return {
|
|
84
|
+
messages: toMessages(rec),
|
|
85
|
+
reward: null, // §B.6 #22 default
|
|
86
|
+
metadata: {
|
|
87
|
+
id: rec.id,
|
|
88
|
+
taskId: rec.taskId,
|
|
89
|
+
agentName: rec.agentName,
|
|
90
|
+
workerProvider: rec.workerProvider,
|
|
91
|
+
workerModel: rec.workerModel,
|
|
92
|
+
outcome: rec.outcome,
|
|
93
|
+
startedAt: rec.startedAt,
|
|
94
|
+
endedAt: rec.endedAt,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatAxolotl(rec) {
|
|
100
|
+
// ShareGPT role mapping: system→system, user→human, assistant→gpt,
|
|
101
|
+
// tool→tool. Keeps the channel a downstream Axolotl recipe can filter.
|
|
102
|
+
const FROM = { system: 'system', user: 'human', assistant: 'gpt', tool: 'tool' };
|
|
103
|
+
return {
|
|
104
|
+
conversations: toMessages(rec).map(m => ({
|
|
105
|
+
from: FROM[m.role] || m.role,
|
|
106
|
+
value: m.content,
|
|
107
|
+
})),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatOpenAIFT(rec) {
|
|
112
|
+
return { messages: toMessages(rec) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function serialise(rec, format) {
|
|
116
|
+
switch (format) {
|
|
117
|
+
case 'jsonl': return JSON.stringify(rec);
|
|
118
|
+
case 'atropos': return JSON.stringify(formatAtropos(rec));
|
|
119
|
+
case 'axolotl': return JSON.stringify(formatAxolotl(rec));
|
|
120
|
+
case 'openai-ft': return JSON.stringify(formatOpenAIFT(rec));
|
|
121
|
+
default: throw new Error(`unknown format: ${format}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Public API. Returns { count, outFile, format }. Filesystem side effect
|
|
126
|
+
// is a single .jsonl under <outDir> named `trajectories-<format>-<ts>.jsonl`.
|
|
127
|
+
export async function exportTrajectories({
|
|
128
|
+
format,
|
|
129
|
+
configDir,
|
|
130
|
+
outDir,
|
|
131
|
+
since,
|
|
132
|
+
filter = {},
|
|
133
|
+
} = {}) {
|
|
134
|
+
if (!FORMATS.includes(format)) throw new Error(`unknown format: ${format}`);
|
|
135
|
+
const cfgDir = configDir || defaultConfigDir();
|
|
136
|
+
const dest = outDir || path.join(cfgDir, 'exports');
|
|
137
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
138
|
+
|
|
139
|
+
const sinceMs = parseSince(since);
|
|
140
|
+
const outcomeFilter = filter && filter.outcome;
|
|
141
|
+
|
|
142
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
143
|
+
const outFile = path.join(dest, `trajectories-${format}-${stamp}.jsonl`);
|
|
144
|
+
const fd = fs.openSync(outFile, 'w');
|
|
145
|
+
let count = 0;
|
|
146
|
+
try {
|
|
147
|
+
for (const rec of iterRecords(cfgDir)) {
|
|
148
|
+
if (sinceMs !== null && (rec.startedAt || 0) < sinceMs) continue;
|
|
149
|
+
if (outcomeFilter && rec.outcome !== outcomeFilter) continue;
|
|
150
|
+
fs.writeSync(fd, serialise(rec, format) + '\n');
|
|
151
|
+
count++;
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
fs.closeSync(fd);
|
|
155
|
+
}
|
|
156
|
+
return { count, outFile, format };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Parse `outcome=done` style key=value filter strings into a plain object.
|
|
160
|
+
// Exported so the CLI can share the parser.
|
|
161
|
+
export function parseFilterArg(str) {
|
|
162
|
+
if (!str) return {};
|
|
163
|
+
const out = {};
|
|
164
|
+
for (const part of String(str).split(',')) {
|
|
165
|
+
const [k, v] = part.split('=').map(s => s && s.trim());
|
|
166
|
+
if (k && v) out[k] = v;
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// mas/trajectory_store.mjs — Phase A.
|
|
2
|
+
//
|
|
3
|
+
// Persists TrajectoryRecord (spec §3.3) to JSONL on disk plus an
|
|
4
|
+
// in-memory cache. Storage layout:
|
|
5
|
+
// <configDir>/trajectories/<YYYY-MM-DD>/<id>.jsonl
|
|
6
|
+
// One file per trajectory id (a ULID) so concurrent writers never
|
|
7
|
+
// contend. A single in-memory Map<id, record> serves hot reads; cold
|
|
8
|
+
// reads stream the file back through JSON.parse.
|
|
9
|
+
//
|
|
10
|
+
// Phase A scope: write, read, list-by-task. Recall by full-text query
|
|
11
|
+
// lives in mas/index_db.mjs (FTS5). The two stores share the same
|
|
12
|
+
// record but the FTS5 mirror is best-effort — disk JSONL is the
|
|
13
|
+
// source of truth.
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import { redactSecrets } from './redact.mjs';
|
|
20
|
+
import { indexTrajectory as _indexTrajectory } from './index_db.mjs';
|
|
21
|
+
|
|
22
|
+
export const OUTCOME_ENUM = Object.freeze(['done', 'failed', 'abandoned']);
|
|
23
|
+
|
|
24
|
+
const _cache = new Map(); // id → record (capped at CACHE_MAX entries)
|
|
25
|
+
const CACHE_MAX = 256;
|
|
26
|
+
|
|
27
|
+
function defaultConfigDir() {
|
|
28
|
+
return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function trajectoriesDir(configDir) {
|
|
32
|
+
return path.join(configDir, 'trajectories');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Crockford-base32 ULID generator. Monotonic within a single ms by
|
|
36
|
+
// appending a counter — same-millisecond puts stay sortable.
|
|
37
|
+
const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
38
|
+
let _ulidLastMs = 0;
|
|
39
|
+
let _ulidCounter = 0;
|
|
40
|
+
let _ulidLastRandPrefix = '';
|
|
41
|
+
function ulid() {
|
|
42
|
+
let now = Date.now();
|
|
43
|
+
let sameMs = (now === _ulidLastMs);
|
|
44
|
+
if (sameMs) _ulidCounter++;
|
|
45
|
+
else { _ulidLastMs = now; _ulidCounter = 0; }
|
|
46
|
+
let timePart = '';
|
|
47
|
+
let t = now;
|
|
48
|
+
for (let i = 0; i < 10; i++) {
|
|
49
|
+
timePart = ULID_ALPHABET[t % 32] + timePart;
|
|
50
|
+
t = Math.floor(t / 32);
|
|
51
|
+
}
|
|
52
|
+
// Hold the random prefix stable within a single ms so the counter
|
|
53
|
+
// suffix is the ONLY thing that changes — that's what keeps same-ms
|
|
54
|
+
// ULIDs lexicographically sortable (canonical ULID monotonicity).
|
|
55
|
+
if (!sameMs) {
|
|
56
|
+
const rand = crypto.randomBytes(10);
|
|
57
|
+
let randPart = '';
|
|
58
|
+
for (let i = 0; i < 16; i++) {
|
|
59
|
+
randPart += ULID_ALPHABET[rand[i % 10] % 32];
|
|
60
|
+
}
|
|
61
|
+
_ulidLastRandPrefix = randPart.slice(0, 14);
|
|
62
|
+
}
|
|
63
|
+
// Counter suffix (last 2 chars) keeps monotonicity intra-ms.
|
|
64
|
+
const ctr = ULID_ALPHABET[(_ulidCounter >> 5) % 32]
|
|
65
|
+
+ ULID_ALPHABET[_ulidCounter % 32];
|
|
66
|
+
return timePart + _ulidLastRandPrefix + ctr;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function redactTurns(turns) {
|
|
70
|
+
return (turns || []).map(t => ({
|
|
71
|
+
...t,
|
|
72
|
+
content: typeof t.content === 'string' ? redactSecrets(t.content) : t.content,
|
|
73
|
+
thinking: typeof t.thinking === 'string' ? redactSecrets(t.thinking) : t.thinking,
|
|
74
|
+
toolCalls: (t.toolCalls || []).map(c => ({
|
|
75
|
+
...c,
|
|
76
|
+
result: typeof c.result === 'string' ? redactSecrets(c.result) : c.result,
|
|
77
|
+
})),
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function dateBucket(ms) {
|
|
82
|
+
return new Date(ms).toISOString().slice(0, 10); // YYYY-MM-DD
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function recordPath(configDir, bucket, id) {
|
|
86
|
+
return path.join(trajectoriesDir(configDir), bucket, `${id}.jsonl`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cachePush(id, rec) {
|
|
90
|
+
_cache.set(id, rec);
|
|
91
|
+
if (_cache.size > CACHE_MAX) {
|
|
92
|
+
const oldest = _cache.keys().next().value;
|
|
93
|
+
_cache.delete(oldest);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function put(record, opts = {}) {
|
|
98
|
+
if (!record || typeof record !== 'object') {
|
|
99
|
+
throw new TypeError('trajectory_store.put: record must be an object');
|
|
100
|
+
}
|
|
101
|
+
if (!OUTCOME_ENUM.includes(record.outcome)) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`outcome must be one of ${OUTCOME_ENUM.join('|')}, got ${record.outcome}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const configDir = opts.configDir || defaultConfigDir();
|
|
107
|
+
const id = record.id || ulid();
|
|
108
|
+
const stored = {
|
|
109
|
+
...record,
|
|
110
|
+
id,
|
|
111
|
+
systemPrompt: typeof record.systemPrompt === 'string'
|
|
112
|
+
? redactSecrets(record.systemPrompt) : '',
|
|
113
|
+
userMessages: (record.userMessages || []).map(m =>
|
|
114
|
+
typeof m === 'string' ? redactSecrets(m) : m),
|
|
115
|
+
turns: redactTurns(record.turns),
|
|
116
|
+
finalAnswer: typeof record.finalAnswer === 'string'
|
|
117
|
+
? redactSecrets(record.finalAnswer) : '',
|
|
118
|
+
};
|
|
119
|
+
const bucket = dateBucket(stored.startedAt || Date.now());
|
|
120
|
+
const file = recordPath(configDir, bucket, id);
|
|
121
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
122
|
+
fs.writeFileSync(file, JSON.stringify(stored) + '\n');
|
|
123
|
+
// Phase A: FTS5 mirror (spec §4.4). Content is the concatenation of
|
|
124
|
+
// the final answer plus every turn's textual content so a single
|
|
125
|
+
// recall() can surface trajectories by either signal.
|
|
126
|
+
try {
|
|
127
|
+
const ftsContent = [
|
|
128
|
+
stored.finalAnswer || '',
|
|
129
|
+
...(stored.turns || []).map(t => String(t.content || '')),
|
|
130
|
+
].filter(Boolean).join('\n');
|
|
131
|
+
_indexTrajectory({
|
|
132
|
+
trajectory_id: id,
|
|
133
|
+
agent: stored.agentName || '',
|
|
134
|
+
outcome: stored.outcome,
|
|
135
|
+
content: ftsContent,
|
|
136
|
+
}, configDir);
|
|
137
|
+
} catch { /* swallow */ }
|
|
138
|
+
cachePush(id, stored);
|
|
139
|
+
return stored;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function get(id, opts = {}) {
|
|
143
|
+
if (_cache.has(id)) return _cache.get(id);
|
|
144
|
+
const configDir = opts.configDir || defaultConfigDir();
|
|
145
|
+
const root = trajectoriesDir(configDir);
|
|
146
|
+
if (!fs.existsSync(root)) return null;
|
|
147
|
+
for (const bucket of fs.readdirSync(root)) {
|
|
148
|
+
const file = recordPath(configDir, bucket, id);
|
|
149
|
+
if (fs.existsSync(file)) {
|
|
150
|
+
const raw = fs.readFileSync(file, 'utf8').trim();
|
|
151
|
+
const rec = JSON.parse(raw);
|
|
152
|
+
cachePush(id, rec);
|
|
153
|
+
return rec;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function listByTaskId(taskId, opts = {}) {
|
|
160
|
+
const configDir = opts.configDir || defaultConfigDir();
|
|
161
|
+
const root = trajectoriesDir(configDir);
|
|
162
|
+
if (!fs.existsSync(root)) return [];
|
|
163
|
+
const matches = [];
|
|
164
|
+
for (const bucket of fs.readdirSync(root).sort()) {
|
|
165
|
+
const bdir = path.join(root, bucket);
|
|
166
|
+
if (!fs.statSync(bdir).isDirectory()) continue;
|
|
167
|
+
for (const f of fs.readdirSync(bdir).sort()) {
|
|
168
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
169
|
+
try {
|
|
170
|
+
const rec = JSON.parse(fs.readFileSync(path.join(bdir, f), 'utf8'));
|
|
171
|
+
if (rec.taskId === taskId) matches.push(rec);
|
|
172
|
+
} catch { /* skip corrupt */ }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return matches;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Test/maintenance hook.
|
|
179
|
+
export function _resetCache() { _cache.clear(); }
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// User modeler — Phase B (v5 §4.10, §9.2, §0.1 C6).
|
|
2
|
+
//
|
|
3
|
+
// Honcho-equivalent. At session end, take the session's turns and ask
|
|
4
|
+
// the trainer to produce a dialectic update for ~/.lazyclaw/memory/USER.md:
|
|
5
|
+
//
|
|
6
|
+
// ## Thesis — durable facts the user just confirmed
|
|
7
|
+
// ## Antithesis — contradictions to prior model (if any)
|
|
8
|
+
// ## Synthesis — the reconciled, persisted summary
|
|
9
|
+
//
|
|
10
|
+
// The synthesis block is also fed to mas/index_db.mjs as a row of
|
|
11
|
+
// fts_memories with kind='user_model' so recall() can pull user facts
|
|
12
|
+
// at prompt assembly time.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
|
|
18
|
+
import { runTextCompletion } from './provider_adapters.mjs';
|
|
19
|
+
import { redactSecrets, neutralizeRoleLabels } from './redact.mjs';
|
|
20
|
+
|
|
21
|
+
const USER_MD_REL = path.join('memory', 'USER.md');
|
|
22
|
+
const MAX_TRANSCRIPT_CHARS = 16 * 1024;
|
|
23
|
+
const MAX_USER_MD_BYTES = 32 * 1024;
|
|
24
|
+
|
|
25
|
+
export function defaultConfigDir() {
|
|
26
|
+
return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function userModelPath(configDir = defaultConfigDir()) {
|
|
30
|
+
return path.join(configDir, USER_MD_REL);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readUserModel(configDir = defaultConfigDir()) {
|
|
34
|
+
const p = userModelPath(configDir);
|
|
35
|
+
try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function flattenTurns(turns) {
|
|
39
|
+
if (!Array.isArray(turns) || turns.length === 0) return '';
|
|
40
|
+
const text = turns
|
|
41
|
+
.map((t) => {
|
|
42
|
+
const who = t.role === 'user' ? 'User' : t.role === 'assistant' ? 'Assistant' : (t.role || 'unknown');
|
|
43
|
+
return `[${who}] ${neutralizeRoleLabels(String(t.content || ''))}`;
|
|
44
|
+
})
|
|
45
|
+
.join('\n\n');
|
|
46
|
+
return redactSecrets(text).slice(0, MAX_TRANSCRIPT_CHARS);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function updateUserModel({
|
|
50
|
+
sessionTurns,
|
|
51
|
+
provider,
|
|
52
|
+
model,
|
|
53
|
+
apiKey,
|
|
54
|
+
baseUrl,
|
|
55
|
+
fetchImpl,
|
|
56
|
+
configDir = defaultConfigDir(),
|
|
57
|
+
ts = new Date(),
|
|
58
|
+
} = {}) {
|
|
59
|
+
const transcript = flattenTurns(sessionTurns);
|
|
60
|
+
if (!transcript.trim()) return null;
|
|
61
|
+
|
|
62
|
+
const prior = readUserModel(configDir).slice(-8 * 1024);
|
|
63
|
+
const userMessage =
|
|
64
|
+
`Below is a recent session transcript and the current USER model. ` +
|
|
65
|
+
`Update the model using a dialectic structure. Reply in EXACTLY this format:\n\n` +
|
|
66
|
+
`## Thesis\n<bullets of new durable facts about the user>\n\n` +
|
|
67
|
+
`## Antithesis\n<bullets of contradictions with the prior model, if any; "(none)" if none>\n\n` +
|
|
68
|
+
`## Synthesis\n<the reconciled model, ≤ 20 bullets, suitable for permanent storage>\n\n` +
|
|
69
|
+
`Prior USER model (may be empty):\n\n` + (prior || '(empty)') + `\n\n` +
|
|
70
|
+
`Session transcript:\n\n` + transcript;
|
|
71
|
+
|
|
72
|
+
let raw;
|
|
73
|
+
try {
|
|
74
|
+
raw = await runTextCompletion({
|
|
75
|
+
provider, model, system: 'You maintain a durable user model.',
|
|
76
|
+
userMessage, apiKey, baseUrl, fetchImpl,
|
|
77
|
+
});
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return { path: userModelPath(configDir), error: String(err?.message || err) };
|
|
80
|
+
}
|
|
81
|
+
const cleaned = redactSecrets(String(raw || '')).trim();
|
|
82
|
+
if (!cleaned || !/##\s*Synthesis/i.test(cleaned)) return null;
|
|
83
|
+
|
|
84
|
+
const date = (ts instanceof Date ? ts : new Date(ts)).toISOString().slice(0, 10);
|
|
85
|
+
const header = `# USER\n\n_Last updated ${date}_\n\n`;
|
|
86
|
+
let body = header + cleaned + '\n';
|
|
87
|
+
if (Buffer.byteLength(body, 'utf8') > MAX_USER_MD_BYTES) {
|
|
88
|
+
body = Buffer.from(body, 'utf8').subarray(0, MAX_USER_MD_BYTES).toString('utf8') + '\n…[truncated]\n';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const p = userModelPath(configDir);
|
|
92
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
93
|
+
const tmp = p + '.tmp';
|
|
94
|
+
fs.writeFileSync(tmp, body);
|
|
95
|
+
fs.renameSync(tmp, p);
|
|
96
|
+
|
|
97
|
+
// Best-effort FTS5 mirror — only the Synthesis section is indexed.
|
|
98
|
+
try {
|
|
99
|
+
const synth = (cleaned.match(/##\s*Synthesis\s*\n([\s\S]*?)(?=\n##\s|$)/i) || [, ''])[1].trim();
|
|
100
|
+
if (synth) {
|
|
101
|
+
const idx = await import('./index_db.mjs');
|
|
102
|
+
idx.openIndex(configDir);
|
|
103
|
+
idx.indexMemory({ topic: 'USER', kind: 'user_model', content: synth }, configDir);
|
|
104
|
+
}
|
|
105
|
+
} catch { /* non-fatal */ }
|
|
106
|
+
|
|
107
|
+
return { path: p, body };
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -31,8 +31,12 @@
|
|
|
31
31
|
"lazyclaw": "cli.mjs"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"test": "playwright test",
|
|
35
|
-
"test:bench": "node scripts/bench-providers.mjs"
|
|
34
|
+
"test": "node --test tests/phaseC-*.test.mjs && playwright test",
|
|
35
|
+
"test:bench": "node scripts/bench-providers.mjs",
|
|
36
|
+
"test:bench:index": "node --test tests/index_store.bench.mjs",
|
|
37
|
+
"test:perf": "node --test tests/phaseH-perf.test.mjs tests/index_store.bench.mjs",
|
|
38
|
+
"migrate:v5": "node scripts/migrate-v5.mjs",
|
|
39
|
+
"build:splash": "node scripts/build-splash.mjs"
|
|
36
40
|
},
|
|
37
41
|
"files": [
|
|
38
42
|
"cli.mjs",
|
|
@@ -47,7 +51,9 @@
|
|
|
47
51
|
"workspace.mjs",
|
|
48
52
|
"browse.mjs",
|
|
49
53
|
"sandbox.mjs",
|
|
54
|
+
"sandbox/",
|
|
50
55
|
"skills_install.mjs",
|
|
56
|
+
"skills_curator.mjs",
|
|
51
57
|
"cron.mjs",
|
|
52
58
|
"loop-engine.mjs",
|
|
53
59
|
"loops.mjs",
|
|
@@ -61,14 +67,27 @@
|
|
|
61
67
|
"workflow/",
|
|
62
68
|
"web/",
|
|
63
69
|
"mas/",
|
|
70
|
+
"gateway/",
|
|
64
71
|
"docs/multi-agent.md",
|
|
65
72
|
"scripts/loop-worker.mjs",
|
|
73
|
+
"scripts/migrate-v5.mjs",
|
|
74
|
+
"scripts/hermes-import.mjs",
|
|
75
|
+
"scripts/openclaw-import.mjs",
|
|
66
76
|
"README.md",
|
|
67
77
|
"LICENSE"
|
|
68
78
|
],
|
|
69
79
|
"engines": {
|
|
70
80
|
"node": ">=18"
|
|
71
81
|
},
|
|
82
|
+
"dependencies": {
|
|
83
|
+
"@modelcontextprotocol/sdk": "^1.18.0",
|
|
84
|
+
"better-sqlite3": "^11.10.0",
|
|
85
|
+
"chalk": "^5.3.0",
|
|
86
|
+
"ink": "^5.0.1",
|
|
87
|
+
"node-ssh": "^13.2.0",
|
|
88
|
+
"string-width": "^7.2.0",
|
|
89
|
+
"undici": "^6.21.0"
|
|
90
|
+
},
|
|
72
91
|
"devDependencies": {
|
|
73
92
|
"@playwright/test": "^1.59.1",
|
|
74
93
|
"@types/node": "^25.6.0",
|