@c4t4/heyamigo 0.5.0 → 0.7.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/config/config.example.json +2 -1
- package/config/memory-instructions.md +22 -0
- package/dist/ai/claude.js +42 -75
- package/dist/ai/spawn.js +121 -0
- package/dist/config.js +1 -0
- package/dist/gateway/incoming.js +20 -6
- package/dist/gateway/outgoing.js +59 -2
- package/dist/memory/compressed.js +303 -0
- package/dist/memory/digest.js +31 -56
- package/dist/memory/journal-nudger.js +27 -58
- package/dist/memory/journal-observer.js +27 -58
- package/dist/memory/preamble.js +9 -0
- package/dist/memory/scheduler.js +11 -0
- package/dist/queue/async-tasks.js +27 -58
- package/dist/queue/worker.js +19 -1
- package/package.json +1 -1
|
@@ -23,6 +23,28 @@ storage/memory/
|
|
|
23
23
|
|
|
24
24
|
Relevant blocks from these files are surfaced to you in the `[Memory: ...]` sections at the top of each turn. You don't need to re-read a file that's already in your preamble.
|
|
25
25
|
|
|
26
|
+
## Rolling state index — [State: current]
|
|
27
|
+
|
|
28
|
+
At the top of every turn, you get `[State: current]`: a rolling index across all people, chats, buckets, and active journals. One to three lines per entity. This is your cheat sheet.
|
|
29
|
+
|
|
30
|
+
It is an **index**, not a summary. Each entry carries load-bearing facts + a path to the full file. Everything else lives in the full profile / brief / entries.
|
|
31
|
+
|
|
32
|
+
### Dig-deeper heuristic
|
|
33
|
+
|
|
34
|
+
- **Passing reference** ("Dani said X in passing"): the compressed line is enough. Answer.
|
|
35
|
+
- **Deep conversation about someone** ("let's dig into Cata's gut protocol"): Read the full file.
|
|
36
|
+
- **Identity, medical, or rule cue** (pronouns, symptoms, relationship, hard rules): verify against the full file before responding. Laziness here is expensive.
|
|
37
|
+
- **Already Read this session**: the content is still in your context. Do NOT re-Read.
|
|
38
|
+
- **Unfamiliar topic or entity**: Read.
|
|
39
|
+
|
|
40
|
+
You decide. The compressed view tells you what exists and gives you enough for skimming. It does not try to replace the full files.
|
|
41
|
+
|
|
42
|
+
Do NOT edit `storage/memory/compressed.md` yourself. It is auto-regenerated after digests and on boot.
|
|
43
|
+
|
|
44
|
+
## Reply footer (system-generated)
|
|
45
|
+
|
|
46
|
+
Your replies are auto-suffixed with a tiny stats line on send — duration, tokens, context %, flags fired. You do NOT write this line. Do NOT mimic it. Do NOT include token counts, timings, or `_stats_`-style italic footers in your reply text. The system adds them; you focus on the message.
|
|
47
|
+
|
|
26
48
|
## DIGEST flag
|
|
27
49
|
|
|
28
50
|
When something in the conversation is worth remembering long-term, append this marker to the END of your reply:
|
package/dist/ai/claude.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { spawn } from 'child_process';
|
|
2
1
|
import { readFileSync } from 'fs';
|
|
3
2
|
import { resolve } from 'path';
|
|
4
3
|
import { config } from '../config.js';
|
|
5
4
|
import { logger } from '../logger.js';
|
|
6
5
|
import { logPrompt } from '../promptlog.js';
|
|
6
|
+
import { runClaude, TIMEOUT_MS } from './spawn.js';
|
|
7
7
|
let cachedSystemPrompt = null;
|
|
8
8
|
function systemPrompt() {
|
|
9
9
|
if (cachedSystemPrompt !== null)
|
|
@@ -56,80 +56,47 @@ function buildArgs(params) {
|
|
|
56
56
|
}
|
|
57
57
|
export async function askClaude(params) {
|
|
58
58
|
const args = buildArgs(params);
|
|
59
|
-
const startedAt = Date.now();
|
|
60
59
|
logger.debug({ resume: !!params.sessionId, inputChars: params.input.length }, 'spawning claude');
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
let stdout = '';
|
|
67
|
-
let stderr = '';
|
|
68
|
-
child.stdout.on('data', (chunk) => {
|
|
69
|
-
stdout += chunk.toString('utf-8');
|
|
70
|
-
});
|
|
71
|
-
child.stderr.on('data', (chunk) => {
|
|
72
|
-
stderr += chunk.toString('utf-8');
|
|
73
|
-
});
|
|
74
|
-
child.on('error', (err) => {
|
|
75
|
-
void logPrompt({
|
|
76
|
-
ts: Math.floor(startedAt / 1000),
|
|
77
|
-
caller: 'worker',
|
|
78
|
-
args,
|
|
79
|
-
input: params.input,
|
|
80
|
-
error: `spawn failed: ${err.message}`,
|
|
81
|
-
durationMs: Date.now() - startedAt,
|
|
82
|
-
});
|
|
83
|
-
rejectPromise(new Error(`claude spawn failed: ${err.message}`));
|
|
84
|
-
});
|
|
85
|
-
child.on('close', (code) => {
|
|
86
|
-
if (code !== 0) {
|
|
87
|
-
void logPrompt({
|
|
88
|
-
ts: Math.floor(startedAt / 1000),
|
|
89
|
-
caller: 'worker',
|
|
90
|
-
args,
|
|
91
|
-
input: params.input,
|
|
92
|
-
error: `exit ${code}: ${stderr.slice(0, 500)}`,
|
|
93
|
-
durationMs: Date.now() - startedAt,
|
|
94
|
-
});
|
|
95
|
-
return rejectPromise(new Error(`claude exited with code ${code}: ${stderr.slice(0, 500)}`));
|
|
96
|
-
}
|
|
97
|
-
try {
|
|
98
|
-
const parsed = JSON.parse(stdout);
|
|
99
|
-
if (parsed.is_error || parsed.subtype !== 'success') {
|
|
100
|
-
return rejectPromise(new Error(`claude returned error (subtype=${parsed.subtype}): ${parsed.result ?? stderr.slice(0, 200)}`));
|
|
101
|
-
}
|
|
102
|
-
if (!parsed.result || !parsed.session_id) {
|
|
103
|
-
return rejectPromise(new Error(`claude output missing result or session_id: ${stdout.slice(0, 200)}`));
|
|
104
|
-
}
|
|
105
|
-
const result = {
|
|
106
|
-
reply: parsed.result,
|
|
107
|
-
sessionId: parsed.session_id,
|
|
108
|
-
usage: {
|
|
109
|
-
inputTokens: parsed.usage?.input_tokens ?? 0,
|
|
110
|
-
cacheReadTokens: parsed.usage?.cache_read_input_tokens ?? 0,
|
|
111
|
-
cacheCreationTokens: parsed.usage?.cache_creation_input_tokens ?? 0,
|
|
112
|
-
outputTokens: parsed.usage?.output_tokens ?? 0,
|
|
113
|
-
numTurns: parsed.num_turns ?? 0,
|
|
114
|
-
},
|
|
115
|
-
};
|
|
116
|
-
void logPrompt({
|
|
117
|
-
ts: Math.floor(startedAt / 1000),
|
|
118
|
-
caller: 'worker',
|
|
119
|
-
args,
|
|
120
|
-
input: params.input,
|
|
121
|
-
output: result.reply,
|
|
122
|
-
sessionId: result.sessionId,
|
|
123
|
-
usage: result.usage,
|
|
124
|
-
durationMs: Date.now() - startedAt,
|
|
125
|
-
});
|
|
126
|
-
resolvePromise(result);
|
|
127
|
-
}
|
|
128
|
-
catch (err) {
|
|
129
|
-
rejectPromise(new Error(`failed to parse claude output: ${err.message}\nstdout: ${stdout.slice(0, 500)}`));
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
child.stdin.write(params.input);
|
|
133
|
-
child.stdin.end();
|
|
60
|
+
const { stdout, durationMs } = await runClaude({
|
|
61
|
+
args,
|
|
62
|
+
input: params.input,
|
|
63
|
+
timeoutMs: TIMEOUT_MS.main,
|
|
64
|
+
caller: 'worker',
|
|
134
65
|
});
|
|
66
|
+
const startedAt = Date.now() - durationMs;
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(stdout);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
throw new Error(`failed to parse claude output: ${err.message}\nstdout: ${stdout.slice(0, 500)}`);
|
|
73
|
+
}
|
|
74
|
+
if (parsed.is_error || parsed.subtype !== 'success') {
|
|
75
|
+
throw new Error(`claude returned error (subtype=${parsed.subtype}): ${parsed.result ?? ''}`);
|
|
76
|
+
}
|
|
77
|
+
if (!parsed.result || !parsed.session_id) {
|
|
78
|
+
throw new Error(`claude output missing result or session_id: ${stdout.slice(0, 200)}`);
|
|
79
|
+
}
|
|
80
|
+
const result = {
|
|
81
|
+
reply: parsed.result,
|
|
82
|
+
sessionId: parsed.session_id,
|
|
83
|
+
usage: {
|
|
84
|
+
inputTokens: parsed.usage?.input_tokens ?? 0,
|
|
85
|
+
cacheReadTokens: parsed.usage?.cache_read_input_tokens ?? 0,
|
|
86
|
+
cacheCreationTokens: parsed.usage?.cache_creation_input_tokens ?? 0,
|
|
87
|
+
outputTokens: parsed.usage?.output_tokens ?? 0,
|
|
88
|
+
numTurns: parsed.num_turns ?? 0,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
void logPrompt({
|
|
92
|
+
ts: Math.floor(startedAt / 1000),
|
|
93
|
+
caller: 'worker',
|
|
94
|
+
args,
|
|
95
|
+
input: params.input,
|
|
96
|
+
output: result.reply,
|
|
97
|
+
sessionId: result.sessionId,
|
|
98
|
+
usage: result.usage,
|
|
99
|
+
durationMs,
|
|
100
|
+
});
|
|
101
|
+
return result;
|
|
135
102
|
}
|
package/dist/ai/spawn.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { logger } from '../logger.js';
|
|
3
|
+
import { logPrompt } from '../promptlog.js';
|
|
4
|
+
export class ClaudeTimeoutError extends Error {
|
|
5
|
+
caller;
|
|
6
|
+
durationMs;
|
|
7
|
+
timeoutMs;
|
|
8
|
+
constructor(caller, durationMs, timeoutMs) {
|
|
9
|
+
super(`${caller} timed out after ${Math.round(durationMs / 1000)}s (cap ${Math.round(timeoutMs / 1000)}s)`);
|
|
10
|
+
this.caller = caller;
|
|
11
|
+
this.durationMs = durationMs;
|
|
12
|
+
this.timeoutMs = timeoutMs;
|
|
13
|
+
this.name = 'ClaudeTimeoutError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class ClaudeSpawnError extends Error {
|
|
17
|
+
caller;
|
|
18
|
+
constructor(caller, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.caller = caller;
|
|
21
|
+
this.name = 'ClaudeSpawnError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Kill the process group of a detached child. Playwright MCP and any Chromium
|
|
25
|
+
// children sit under the claude subprocess; without process-group kill they
|
|
26
|
+
// linger after we SIGTERM the parent and accumulate on the host.
|
|
27
|
+
function killGroup(child, signal) {
|
|
28
|
+
if (!child.pid)
|
|
29
|
+
return;
|
|
30
|
+
try {
|
|
31
|
+
// Negative PID = target the whole process group (see kill(2)).
|
|
32
|
+
process.kill(-child.pid, signal);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
// Fallback: signal just the parent. Better than nothing.
|
|
36
|
+
try {
|
|
37
|
+
child.kill(signal);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
logger.warn({ err, pid: child.pid }, 'failed to kill claude subprocess');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Run a `claude -p ...` subprocess with a hard timeout, full-tree kill on
|
|
45
|
+
// expiry, and uniform promptlog handling. All claude spawns in the codebase
|
|
46
|
+
// should go through this.
|
|
47
|
+
export async function runClaude(opts) {
|
|
48
|
+
const { args, input, timeoutMs, caller } = opts;
|
|
49
|
+
const startedAt = Date.now();
|
|
50
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
51
|
+
const child = spawn('claude', args, {
|
|
52
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
53
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
54
|
+
// detached:true puts the child in its own process group, so killGroup
|
|
55
|
+
// can SIGTERM the whole tree (Playwright MCP, Chromium, etc.) at once.
|
|
56
|
+
detached: true,
|
|
57
|
+
});
|
|
58
|
+
let stdout = '';
|
|
59
|
+
let stderr = '';
|
|
60
|
+
let timedOut = false;
|
|
61
|
+
let settled = false;
|
|
62
|
+
const logFail = (error) => void logPrompt({
|
|
63
|
+
ts: Math.floor(startedAt / 1000),
|
|
64
|
+
caller,
|
|
65
|
+
args,
|
|
66
|
+
input,
|
|
67
|
+
error,
|
|
68
|
+
durationMs: Date.now() - startedAt,
|
|
69
|
+
});
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
timedOut = true;
|
|
72
|
+
logger.warn({ caller, pid: child.pid, timeoutMs }, 'claude subprocess timed out, killing process group');
|
|
73
|
+
killGroup(child, 'SIGTERM');
|
|
74
|
+
// Grace window, then SIGKILL if still alive.
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
if (!settled)
|
|
77
|
+
killGroup(child, 'SIGKILL');
|
|
78
|
+
}, 2000).unref();
|
|
79
|
+
}, timeoutMs);
|
|
80
|
+
timer.unref();
|
|
81
|
+
child.stdout.on('data', (chunk) => {
|
|
82
|
+
stdout += chunk.toString('utf-8');
|
|
83
|
+
});
|
|
84
|
+
child.stderr.on('data', (chunk) => {
|
|
85
|
+
stderr += chunk.toString('utf-8');
|
|
86
|
+
});
|
|
87
|
+
child.on('error', (err) => {
|
|
88
|
+
if (settled)
|
|
89
|
+
return;
|
|
90
|
+
settled = true;
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
logFail(`spawn failed: ${err.message}`);
|
|
93
|
+
rejectPromise(new ClaudeSpawnError(caller, `claude spawn failed: ${err.message}`));
|
|
94
|
+
});
|
|
95
|
+
child.on('close', (code, signal) => {
|
|
96
|
+
if (settled)
|
|
97
|
+
return;
|
|
98
|
+
settled = true;
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
const durationMs = Date.now() - startedAt;
|
|
101
|
+
if (timedOut) {
|
|
102
|
+
logFail(`timeout after ${durationMs}ms (cap ${timeoutMs}ms); signal=${signal}`);
|
|
103
|
+
return rejectPromise(new ClaudeTimeoutError(caller, durationMs, timeoutMs));
|
|
104
|
+
}
|
|
105
|
+
if (code !== 0) {
|
|
106
|
+
logFail(`exit ${code}: ${stderr.slice(0, 500)}`);
|
|
107
|
+
return rejectPromise(new ClaudeSpawnError(caller, `claude exited with code ${code}: ${stderr.slice(0, 500)}`));
|
|
108
|
+
}
|
|
109
|
+
resolvePromise({ stdout, durationMs });
|
|
110
|
+
});
|
|
111
|
+
child.stdin.write(input);
|
|
112
|
+
child.stdin.end();
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
// Per-lane defaults. Individual callers can override, but these are the
|
|
116
|
+
// shipped caps. Browser-heavy work lives in the async lane.
|
|
117
|
+
export const TIMEOUT_MS = {
|
|
118
|
+
main: 5 * 60 * 1000,
|
|
119
|
+
async: 15 * 60 * 1000,
|
|
120
|
+
background: 3 * 60 * 1000,
|
|
121
|
+
};
|
package/dist/config.js
CHANGED
package/dist/gateway/incoming.js
CHANGED
|
@@ -166,15 +166,29 @@ async function processMessages(messages, sock, ownerJid, isHistorySync = false)
|
|
|
166
166
|
clearInterval(typingHeartbeat);
|
|
167
167
|
typingHeartbeat = null;
|
|
168
168
|
};
|
|
169
|
+
// Defense-in-depth: if nothing else clears the heartbeat within 10 min
|
|
170
|
+
// (e.g. a code path forgot), force-stop. Prevents runaway "typing..."
|
|
171
|
+
// indicators when the pipeline silently fails.
|
|
172
|
+
const typingSafetyCap = setTimeout(() => {
|
|
173
|
+
if (typingHeartbeat) {
|
|
174
|
+
logger.warn({ jid: job.jid }, 'typingHeartbeat safety-cap fired, forcing clear');
|
|
175
|
+
stopTyping();
|
|
176
|
+
}
|
|
177
|
+
}, 10 * 60 * 1000);
|
|
178
|
+
typingSafetyCap.unref();
|
|
169
179
|
enqueue(job)
|
|
170
|
-
.then((result) =>
|
|
171
|
-
stopTyping();
|
|
172
|
-
return handleReply(job, result, msg);
|
|
173
|
-
})
|
|
180
|
+
.then((result) => handleReply(job, result, msg))
|
|
174
181
|
.catch((err) => {
|
|
182
|
+
const isTimeout = err instanceof Error && err.name === 'ClaudeTimeoutError';
|
|
183
|
+
logger.error({ err, jid: job.jid, isTimeout }, 'pipeline failed');
|
|
184
|
+
const replyText = isTimeout
|
|
185
|
+
? 'That request timed out. The task was cancelled, queue is moving.'
|
|
186
|
+
: config.reply.errorMessage;
|
|
187
|
+
return handleReply(job, { reply: replyText }, msg).catch((e) => logger.error({ err: e, jid: job.jid }, 'failed to send error reply'));
|
|
188
|
+
})
|
|
189
|
+
.finally(() => {
|
|
175
190
|
stopTyping();
|
|
176
|
-
|
|
177
|
-
void handleReply(job, { reply: config.reply.errorMessage }, msg).catch((e) => logger.error({ err: e, jid: job.jid }, 'failed to send error reply'));
|
|
191
|
+
clearTimeout(typingSafetyCap);
|
|
178
192
|
});
|
|
179
193
|
}
|
|
180
194
|
catch (err) {
|
package/dist/gateway/outgoing.js
CHANGED
|
@@ -33,6 +33,9 @@ export async function handleReply(job, result, originalMsg) {
|
|
|
33
33
|
const { text, files } = extractFiles(raw);
|
|
34
34
|
const isGroup = isJidGroup(job.jid) === true;
|
|
35
35
|
const quoted = isGroup && config.reply.quoteInGroups ? originalMsg : undefined;
|
|
36
|
+
const footer = result.stats && config.reply.showStats
|
|
37
|
+
? formatStatsFooter(result.stats)
|
|
38
|
+
: '';
|
|
36
39
|
try {
|
|
37
40
|
// Send files first (images, videos, PDFs, audio, etc.)
|
|
38
41
|
for (const filePath of files) {
|
|
@@ -43,7 +46,15 @@ export async function handleReply(job, result, originalMsg) {
|
|
|
43
46
|
const caption = isFirst && text && text.length <= 1000 && files.length === 1 && supportsCaption
|
|
44
47
|
? text
|
|
45
48
|
: undefined;
|
|
46
|
-
|
|
49
|
+
// Append footer to caption at send time only (not to storage). Only
|
|
50
|
+
// when this media file is the final user-facing payload (no text
|
|
51
|
+
// coming after, single file with caption case).
|
|
52
|
+
const willHaveTextAfter = !!text &&
|
|
53
|
+
!(files.length === 1 && text.length <= 1000 && supportsCaption);
|
|
54
|
+
const captionForSend = caption && footer && !willHaveTextAfter
|
|
55
|
+
? `${caption}\n\n${footer}`
|
|
56
|
+
: caption;
|
|
57
|
+
await sendFile(sock, job.jid, filePath, captionForSend, isFirst ? quoted : undefined);
|
|
47
58
|
await append({
|
|
48
59
|
id: `reply-file-${Date.now()}`,
|
|
49
60
|
jid: job.jid,
|
|
@@ -73,7 +84,9 @@ export async function handleReply(job, result, originalMsg) {
|
|
|
73
84
|
for (let i = 0; i < chunks.length; i++) {
|
|
74
85
|
const chunk = chunks[i];
|
|
75
86
|
const q = i === 0 && files.length === 0 ? quoted : undefined;
|
|
76
|
-
|
|
87
|
+
const isLast = i === chunks.length - 1;
|
|
88
|
+
const chunkForSend = isLast && footer ? `${chunk}\n\n${footer}` : chunk;
|
|
89
|
+
await sendText(sock, job.jid, chunkForSend, q);
|
|
77
90
|
await append({
|
|
78
91
|
id: `reply-${Date.now()}-${i}`,
|
|
79
92
|
jid: job.jid,
|
|
@@ -107,6 +120,50 @@ export async function handleReply(job, result, originalMsg) {
|
|
|
107
120
|
function sleep(ms) {
|
|
108
121
|
return new Promise((r) => setTimeout(r, ms));
|
|
109
122
|
}
|
|
123
|
+
// Append-only-at-send footer. Never stored, never in Claude's recent-context
|
|
124
|
+
// feedback loop. Adaptive: shows only what's interesting for this reply.
|
|
125
|
+
export function formatStatsFooter(stats) {
|
|
126
|
+
const parts = [];
|
|
127
|
+
// Duration — always
|
|
128
|
+
const secs = stats.durationMs / 1000;
|
|
129
|
+
parts.push(secs < 10 ? `${secs.toFixed(1)}s` : `${Math.round(secs)}s`);
|
|
130
|
+
// Tokens in / out — always. Show cache hit only when it's meaningful.
|
|
131
|
+
const inStr = compactTokens(stats.inputTokens + stats.cacheReadTokens);
|
|
132
|
+
const outStr = compactTokens(stats.outputTokens);
|
|
133
|
+
const cacheStr = stats.cacheReadTokens >= 500
|
|
134
|
+
? ` (${compactTokens(stats.cacheReadTokens)} cached)`
|
|
135
|
+
: '';
|
|
136
|
+
parts.push(`${inStr}↑${cacheStr} ${outStr}↓`);
|
|
137
|
+
// Context % — only when worth calling out
|
|
138
|
+
if (stats.contextWindow > 0) {
|
|
139
|
+
const pct = Math.round((stats.totalContextTokens / stats.contextWindow) * 100);
|
|
140
|
+
if (pct >= 90)
|
|
141
|
+
parts.push(`⚠ ${pct}% ctx`);
|
|
142
|
+
else if (pct >= 70)
|
|
143
|
+
parts.push(`${pct}% ctx`);
|
|
144
|
+
}
|
|
145
|
+
// Fresh session — resume is default, says nothing
|
|
146
|
+
if (stats.fresh)
|
|
147
|
+
parts.push('fresh');
|
|
148
|
+
// Journal flagged — show each slug (usually 0 or 1)
|
|
149
|
+
for (const slug of stats.journalSlugs)
|
|
150
|
+
parts.push(`+journal:${slug}`);
|
|
151
|
+
// Digest fired
|
|
152
|
+
if (stats.hasDigest)
|
|
153
|
+
parts.push('+digest');
|
|
154
|
+
// Async spawned
|
|
155
|
+
if (stats.asyncCount > 0) {
|
|
156
|
+
parts.push(stats.asyncCount === 1 ? '+async' : `+${stats.asyncCount} async`);
|
|
157
|
+
}
|
|
158
|
+
return `_${parts.join(' · ')}_`;
|
|
159
|
+
}
|
|
160
|
+
function compactTokens(n) {
|
|
161
|
+
if (n < 1000)
|
|
162
|
+
return String(n);
|
|
163
|
+
if (n < 10_000)
|
|
164
|
+
return `${(n / 1000).toFixed(1)}k`;
|
|
165
|
+
return `${Math.round(n / 1000)}k`;
|
|
166
|
+
}
|
|
110
167
|
// Proactive outbound: send a message to a chat without an incoming trigger.
|
|
111
168
|
// Chunks, persists to the message log, never throws. Callers are responsible
|
|
112
169
|
// for the canSendProactive() gate — this function does not re-check it.
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, } from 'fs';
|
|
2
|
+
import { dirname, resolve } from 'path';
|
|
3
|
+
import { mkdirSync } from 'fs';
|
|
4
|
+
import { runClaude, TIMEOUT_MS } from '../ai/spawn.js';
|
|
5
|
+
import { config } from '../config.js';
|
|
6
|
+
import { logger } from '../logger.js';
|
|
7
|
+
import { logPrompt } from '../promptlog.js';
|
|
8
|
+
import { listJournals, readEntries } from './journals.js';
|
|
9
|
+
import { memoryRoot, treeRoot, entityIndexPath } from './paths.js';
|
|
10
|
+
// The compressed view is a rolling index across all memory: people, chats,
|
|
11
|
+
// buckets, active journals. 1-3 lines per entity. Purpose: every fresh
|
|
12
|
+
// session starts with enough state to respond to a passing mention without
|
|
13
|
+
// re-reading any file. Deep context still lives in the full profile/brief/
|
|
14
|
+
// entries files — Claude reads those on demand.
|
|
15
|
+
//
|
|
16
|
+
// Regenerated on: boot, and after any digest that edits a memory file
|
|
17
|
+
// (marked dirty, lazily rebuilt on next access).
|
|
18
|
+
export function compressedPath() {
|
|
19
|
+
return resolve(memoryRoot(), 'compressed.md');
|
|
20
|
+
}
|
|
21
|
+
function compressedStatePath() {
|
|
22
|
+
return resolve(memoryRoot(), 'compressed-state.json');
|
|
23
|
+
}
|
|
24
|
+
function loadState() {
|
|
25
|
+
const raw = readIfExists(compressedStatePath());
|
|
26
|
+
if (!raw)
|
|
27
|
+
return { lastBuiltAt: 0, dirty: true };
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(raw);
|
|
30
|
+
return {
|
|
31
|
+
lastBuiltAt: parsed.lastBuiltAt ?? 0,
|
|
32
|
+
dirty: parsed.dirty ?? false,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { lastBuiltAt: 0, dirty: true };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function saveState(state) {
|
|
40
|
+
const path = compressedStatePath();
|
|
41
|
+
ensureDirFor(path);
|
|
42
|
+
writeFileSync(path, JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
|
43
|
+
}
|
|
44
|
+
function readIfExists(path) {
|
|
45
|
+
if (!existsSync(path))
|
|
46
|
+
return null;
|
|
47
|
+
return readFileSync(path, 'utf-8');
|
|
48
|
+
}
|
|
49
|
+
function ensureDirFor(path) {
|
|
50
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
export function markCompressedDirty() {
|
|
53
|
+
const state = loadState();
|
|
54
|
+
state.dirty = true;
|
|
55
|
+
saveState(state);
|
|
56
|
+
}
|
|
57
|
+
export function readCompressed() {
|
|
58
|
+
return readIfExists(compressedPath());
|
|
59
|
+
}
|
|
60
|
+
// ---------- generator ----------
|
|
61
|
+
function collectPersons() {
|
|
62
|
+
const dir = treeRoot('persons');
|
|
63
|
+
if (!existsSync(dir))
|
|
64
|
+
return [];
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const d of readdirSync(dir, { withFileTypes: true })) {
|
|
67
|
+
if (!d.isDirectory())
|
|
68
|
+
continue;
|
|
69
|
+
const profilePath = resolve(dir, d.name, 'profile.md');
|
|
70
|
+
const profile = readIfExists(profilePath);
|
|
71
|
+
if (profile)
|
|
72
|
+
out.push({ number: d.name, profile });
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
function collectChats() {
|
|
77
|
+
const dir = treeRoot('chats');
|
|
78
|
+
if (!existsSync(dir))
|
|
79
|
+
return [];
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const d of readdirSync(dir, { withFileTypes: true })) {
|
|
82
|
+
if (!d.isDirectory())
|
|
83
|
+
continue;
|
|
84
|
+
const briefPath = resolve(dir, d.name, 'brief.md');
|
|
85
|
+
const brief = readIfExists(briefPath);
|
|
86
|
+
if (brief)
|
|
87
|
+
out.push({ jid: d.name, brief });
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
function collectBuckets() {
|
|
92
|
+
const dir = treeRoot('buckets');
|
|
93
|
+
if (!existsSync(dir))
|
|
94
|
+
return [];
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const d of readdirSync(dir, { withFileTypes: true })) {
|
|
97
|
+
if (!d.isDirectory())
|
|
98
|
+
continue;
|
|
99
|
+
const idx = readIfExists(entityIndexPath('buckets', d.name));
|
|
100
|
+
if (idx)
|
|
101
|
+
out.push({ slug: d.name, index: idx });
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
function collectJournals() {
|
|
106
|
+
return listJournals()
|
|
107
|
+
.filter((j) => j.status === 'active')
|
|
108
|
+
.map((j) => {
|
|
109
|
+
const cadenceBits = [];
|
|
110
|
+
if (j.cadence.checkin)
|
|
111
|
+
cadenceBits.push(`check-in ${j.cadence.checkin}`);
|
|
112
|
+
if (j.cadence.nudge_if_silent)
|
|
113
|
+
cadenceBits.push(`silent-nudge ${j.cadence.nudge_if_silent}`);
|
|
114
|
+
const recent = readEntries(j.slug, 2);
|
|
115
|
+
return {
|
|
116
|
+
slug: j.slug,
|
|
117
|
+
purpose: j.purpose,
|
|
118
|
+
status: j.status,
|
|
119
|
+
cadence: cadenceBits.join(', '),
|
|
120
|
+
lastEntries: recent.map((e) => {
|
|
121
|
+
const d = new Date(e.ts * 1000)
|
|
122
|
+
.toISOString()
|
|
123
|
+
.slice(0, 10);
|
|
124
|
+
return `[${d}] ${e.note}`;
|
|
125
|
+
}),
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function buildInputForGenerator() {
|
|
130
|
+
const lines = [];
|
|
131
|
+
const persons = collectPersons();
|
|
132
|
+
if (persons.length) {
|
|
133
|
+
lines.push('## PEOPLE (raw profiles)');
|
|
134
|
+
for (const p of persons) {
|
|
135
|
+
lines.push(`### ${p.number}`);
|
|
136
|
+
lines.push(p.profile.trim());
|
|
137
|
+
lines.push('');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const chats = collectChats();
|
|
141
|
+
if (chats.length) {
|
|
142
|
+
lines.push('## CHATS (raw briefs)');
|
|
143
|
+
for (const c of chats) {
|
|
144
|
+
lines.push(`### ${c.jid}`);
|
|
145
|
+
lines.push(c.brief.trim());
|
|
146
|
+
lines.push('');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const buckets = collectBuckets();
|
|
150
|
+
if (buckets.length) {
|
|
151
|
+
lines.push('## BUCKETS (raw indexes)');
|
|
152
|
+
for (const b of buckets) {
|
|
153
|
+
lines.push(`### ${b.slug}`);
|
|
154
|
+
lines.push(b.index.trim());
|
|
155
|
+
lines.push('');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const journals = collectJournals();
|
|
159
|
+
if (journals.length) {
|
|
160
|
+
lines.push('## JOURNALS (active)');
|
|
161
|
+
for (const j of journals) {
|
|
162
|
+
lines.push(`### ${j.slug}`);
|
|
163
|
+
lines.push(`purpose: ${j.purpose}`);
|
|
164
|
+
if (j.cadence)
|
|
165
|
+
lines.push(`cadence: ${j.cadence}`);
|
|
166
|
+
if (j.lastEntries.length) {
|
|
167
|
+
lines.push('last entries:');
|
|
168
|
+
for (const e of j.lastEntries)
|
|
169
|
+
lines.push(` ${e}`);
|
|
170
|
+
}
|
|
171
|
+
lines.push('');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return lines.join('\n') || '(no memory yet)';
|
|
175
|
+
}
|
|
176
|
+
function generatorPrompt(raw) {
|
|
177
|
+
return `Write the line you'd want to see if you woke up with amnesia and were about to answer this person. That's the whole job.
|
|
178
|
+
|
|
179
|
+
You are producing a rolling "state of the world" index. Every fresh Claude session starts by reading it. It is NOT a summary. It is an INDEX with load-bearing excerpts, pointing at full files for depth.
|
|
180
|
+
|
|
181
|
+
RULES (enforce ruthlessly):
|
|
182
|
+
- Every phrase must change a response. If removing it wouldn't change how you'd reply, cut it.
|
|
183
|
+
- Staccato. No filler verbs (is, was, has, tends to). No hedges (maybe, usually, often).
|
|
184
|
+
- Behavior-shifting facts only: identity (pronouns, name), hard rules ("always English", "don't X"), current state ("gut recovering", "bulk to 63kg"), key constraints.
|
|
185
|
+
- NO biography. Age, location, occupation are NOT load-bearing unless they directly change a response.
|
|
186
|
+
- Cap: one to three lines per entity. Closer to one is better.
|
|
187
|
+
- High word/meaning ratio. "Trolls, verify." does the work of a paragraph.
|
|
188
|
+
|
|
189
|
+
OUTPUT FORMAT (exact):
|
|
190
|
+
|
|
191
|
+
# State: current
|
|
192
|
+
|
|
193
|
+
## People
|
|
194
|
+
|
|
195
|
+
- <name> (<number>): <phrase>. <phrase>. <phrase>.
|
|
196
|
+
→ storage/memory/persons/<number>/profile.md
|
|
197
|
+
|
|
198
|
+
(one entry per person, three phrases MAX)
|
|
199
|
+
|
|
200
|
+
## Chats
|
|
201
|
+
|
|
202
|
+
- <jid> "<chat name if known>": <one line of norms + current state>.
|
|
203
|
+
→ storage/memory/chats/<jid>/brief.md
|
|
204
|
+
|
|
205
|
+
## Buckets
|
|
206
|
+
|
|
207
|
+
- <slug>: <one line — what this is + current status>.
|
|
208
|
+
→ storage/memory/buckets/<slug>/index.md
|
|
209
|
+
|
|
210
|
+
## Journals (active, open todos)
|
|
211
|
+
|
|
212
|
+
- <slug>: <purpose, tight>.
|
|
213
|
+
last: <copy the most recent entry VERBATIM, do not paraphrase>
|
|
214
|
+
cadence: <check-in / silent-nudge as applicable>
|
|
215
|
+
→ storage/memory/journals/<slug>/
|
|
216
|
+
|
|
217
|
+
RAW SOURCES:
|
|
218
|
+
|
|
219
|
+
${raw}
|
|
220
|
+
|
|
221
|
+
Output ONLY the compressed index in the exact format above. No preamble, no explanation, no code fences.`;
|
|
222
|
+
}
|
|
223
|
+
async function spawnGenerator(prompt) {
|
|
224
|
+
const args = [
|
|
225
|
+
'-p',
|
|
226
|
+
'--output-format',
|
|
227
|
+
'json',
|
|
228
|
+
'--model',
|
|
229
|
+
config.claude.model,
|
|
230
|
+
'--permission-mode',
|
|
231
|
+
'acceptEdits',
|
|
232
|
+
];
|
|
233
|
+
const { stdout, durationMs } = await runClaude({
|
|
234
|
+
args,
|
|
235
|
+
input: prompt,
|
|
236
|
+
timeoutMs: TIMEOUT_MS.background,
|
|
237
|
+
caller: 'compressed',
|
|
238
|
+
});
|
|
239
|
+
const startedAt = Date.now() - durationMs;
|
|
240
|
+
let parsed;
|
|
241
|
+
try {
|
|
242
|
+
parsed = JSON.parse(stdout);
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
throw new Error(`compressed parse failed: ${err.message}`);
|
|
246
|
+
}
|
|
247
|
+
if (parsed.is_error || parsed.subtype !== 'success' || !parsed.result) {
|
|
248
|
+
throw new Error(`compressed bad output: ${parsed.result ?? stdout.slice(0, 200)}`);
|
|
249
|
+
}
|
|
250
|
+
const output = parsed.result.trim();
|
|
251
|
+
void logPrompt({
|
|
252
|
+
ts: Math.floor(startedAt / 1000),
|
|
253
|
+
caller: 'compressed',
|
|
254
|
+
args,
|
|
255
|
+
input: prompt,
|
|
256
|
+
output,
|
|
257
|
+
durationMs,
|
|
258
|
+
});
|
|
259
|
+
return output;
|
|
260
|
+
}
|
|
261
|
+
let buildInFlight = null;
|
|
262
|
+
export async function rebuildCompressed() {
|
|
263
|
+
if (buildInFlight)
|
|
264
|
+
return buildInFlight;
|
|
265
|
+
buildInFlight = (async () => {
|
|
266
|
+
const raw = buildInputForGenerator();
|
|
267
|
+
if (raw === '(no memory yet)') {
|
|
268
|
+
const empty = '# State: current\n\n(no memory yet)\n';
|
|
269
|
+
const path = compressedPath();
|
|
270
|
+
ensureDirFor(path);
|
|
271
|
+
writeFileSync(path, empty, 'utf-8');
|
|
272
|
+
saveState({ lastBuiltAt: Math.floor(Date.now() / 1000), dirty: false });
|
|
273
|
+
logger.info('compressed: empty scaffold written (no memory yet)');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const prompt = generatorPrompt(raw);
|
|
277
|
+
const output = await spawnGenerator(prompt);
|
|
278
|
+
const path = compressedPath();
|
|
279
|
+
ensureDirFor(path);
|
|
280
|
+
writeFileSync(path, output + '\n', 'utf-8');
|
|
281
|
+
saveState({ lastBuiltAt: Math.floor(Date.now() / 1000), dirty: false });
|
|
282
|
+
logger.info({ chars: output.length }, 'compressed: rebuilt');
|
|
283
|
+
})();
|
|
284
|
+
try {
|
|
285
|
+
await buildInFlight;
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
buildInFlight = null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Regenerate only if dirty or missing. Used in boot + lazy access paths.
|
|
292
|
+
export async function ensureCompressedFresh() {
|
|
293
|
+
const state = loadState();
|
|
294
|
+
const exists = existsSync(compressedPath());
|
|
295
|
+
if (!state.dirty && exists)
|
|
296
|
+
return;
|
|
297
|
+
try {
|
|
298
|
+
await rebuildCompressed();
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
logger.error({ err }, 'compressed: rebuild failed');
|
|
302
|
+
}
|
|
303
|
+
}
|
package/dist/memory/digest.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { runClaude, TIMEOUT_MS } from '../ai/spawn.js';
|
|
2
2
|
import { config } from '../config.js';
|
|
3
3
|
import { logger } from '../logger.js';
|
|
4
4
|
import { logPrompt } from '../promptlog.js';
|
|
5
5
|
import { readLast } from '../store/messages.js';
|
|
6
|
+
import { markCompressedDirty } from './compressed.js';
|
|
6
7
|
import { readBrief, readProfile, setLastDigestedAt, writeBrief, writeProfile, } from './store.js';
|
|
7
8
|
/**
|
|
8
9
|
* Run a stateless Claude call to consolidate memory.
|
|
@@ -18,62 +19,33 @@ async function spawnDigester(prompt) {
|
|
|
18
19
|
'--permission-mode',
|
|
19
20
|
'acceptEdits',
|
|
20
21
|
];
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
});
|
|
47
|
-
child.on('close', (code) => {
|
|
48
|
-
if (code !== 0) {
|
|
49
|
-
logFail(`exit ${code}: ${stderr.slice(0, 300)}`);
|
|
50
|
-
return rejectPromise(new Error(`digester exit ${code}: ${stderr.slice(0, 300)}`));
|
|
51
|
-
}
|
|
52
|
-
try {
|
|
53
|
-
const parsed = JSON.parse(stdout);
|
|
54
|
-
if (parsed.is_error || parsed.subtype !== 'success' || !parsed.result) {
|
|
55
|
-
logFail(`bad output: ${parsed.result ?? stderr.slice(0, 200)}`);
|
|
56
|
-
return rejectPromise(new Error(`digester bad output: ${parsed.result ?? stderr.slice(0, 200)}`));
|
|
57
|
-
}
|
|
58
|
-
const output = parsed.result.trim();
|
|
59
|
-
void logPrompt({
|
|
60
|
-
ts: Math.floor(startedAt / 1000),
|
|
61
|
-
caller: 'digester',
|
|
62
|
-
args,
|
|
63
|
-
input: prompt,
|
|
64
|
-
output,
|
|
65
|
-
durationMs: Date.now() - startedAt,
|
|
66
|
-
});
|
|
67
|
-
resolvePromise(output);
|
|
68
|
-
}
|
|
69
|
-
catch (err) {
|
|
70
|
-
logFail(`parse failed: ${err.message}`);
|
|
71
|
-
rejectPromise(new Error(`digester parse failed: ${err.message}`));
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
child.stdin.write(prompt);
|
|
75
|
-
child.stdin.end();
|
|
22
|
+
const { stdout, durationMs } = await runClaude({
|
|
23
|
+
args,
|
|
24
|
+
input: prompt,
|
|
25
|
+
timeoutMs: TIMEOUT_MS.background,
|
|
26
|
+
caller: 'digester',
|
|
27
|
+
});
|
|
28
|
+
const startedAt = Date.now() - durationMs;
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(stdout);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
throw new Error(`digester parse failed: ${err.message}`);
|
|
35
|
+
}
|
|
36
|
+
if (parsed.is_error || parsed.subtype !== 'success' || !parsed.result) {
|
|
37
|
+
throw new Error(`digester bad output: ${parsed.result ?? stdout.slice(0, 200)}`);
|
|
38
|
+
}
|
|
39
|
+
const output = parsed.result.trim();
|
|
40
|
+
void logPrompt({
|
|
41
|
+
ts: Math.floor(startedAt / 1000),
|
|
42
|
+
caller: 'digester',
|
|
43
|
+
args,
|
|
44
|
+
input: prompt,
|
|
45
|
+
output,
|
|
46
|
+
durationMs,
|
|
76
47
|
});
|
|
48
|
+
return output;
|
|
77
49
|
}
|
|
78
50
|
function formatMessagesForDigest(messages) {
|
|
79
51
|
return messages
|
|
@@ -199,4 +171,7 @@ export async function runDigest(params) {
|
|
|
199
171
|
logger.error({ err, number }, 'profile digest failed');
|
|
200
172
|
}
|
|
201
173
|
}
|
|
174
|
+
// A profile or brief changed. Mark compressed view dirty so the next
|
|
175
|
+
// session boot or ensureCompressedFresh() call regenerates it.
|
|
176
|
+
markCompressedDirty();
|
|
202
177
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { runClaude, TIMEOUT_MS } from '../ai/spawn.js';
|
|
2
2
|
import { config } from '../config.js';
|
|
3
3
|
import { initiate } from '../gateway/outgoing.js';
|
|
4
4
|
import { logger } from '../logger.js';
|
|
@@ -24,64 +24,33 @@ async function spawnComposer(prompt) {
|
|
|
24
24
|
'--permission-mode',
|
|
25
25
|
'acceptEdits',
|
|
26
26
|
];
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
});
|
|
53
|
-
child.on('close', (code) => {
|
|
54
|
-
if (code !== 0) {
|
|
55
|
-
logFail(`exit ${code}: ${stderr.slice(0, 300)}`);
|
|
56
|
-
return rejectPromise(new Error(`nudger exit ${code}`));
|
|
57
|
-
}
|
|
58
|
-
try {
|
|
59
|
-
const parsed = JSON.parse(stdout);
|
|
60
|
-
if (parsed.is_error ||
|
|
61
|
-
parsed.subtype !== 'success' ||
|
|
62
|
-
!parsed.result) {
|
|
63
|
-
logFail(`bad output: ${parsed.result ?? stderr.slice(0, 200)}`);
|
|
64
|
-
return rejectPromise(new Error('nudger bad output'));
|
|
65
|
-
}
|
|
66
|
-
const output = parsed.result.trim();
|
|
67
|
-
void logPrompt({
|
|
68
|
-
ts: Math.floor(startedAt / 1000),
|
|
69
|
-
caller: 'journal-nudger',
|
|
70
|
-
args,
|
|
71
|
-
input: prompt,
|
|
72
|
-
output,
|
|
73
|
-
durationMs: Date.now() - startedAt,
|
|
74
|
-
});
|
|
75
|
-
resolvePromise(output);
|
|
76
|
-
}
|
|
77
|
-
catch (err) {
|
|
78
|
-
logFail(`parse failed: ${err.message}`);
|
|
79
|
-
rejectPromise(err);
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
child.stdin.write(prompt);
|
|
83
|
-
child.stdin.end();
|
|
27
|
+
const { stdout, durationMs } = await runClaude({
|
|
28
|
+
args,
|
|
29
|
+
input: prompt,
|
|
30
|
+
timeoutMs: TIMEOUT_MS.background,
|
|
31
|
+
caller: 'journal-nudger',
|
|
32
|
+
});
|
|
33
|
+
const startedAt = Date.now() - durationMs;
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(stdout);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
throw new Error(`nudger parse failed: ${err.message}`);
|
|
40
|
+
}
|
|
41
|
+
if (parsed.is_error || parsed.subtype !== 'success' || !parsed.result) {
|
|
42
|
+
throw new Error(`nudger bad output: ${parsed.result ?? stdout.slice(0, 200)}`);
|
|
43
|
+
}
|
|
44
|
+
const output = parsed.result.trim();
|
|
45
|
+
void logPrompt({
|
|
46
|
+
ts: Math.floor(startedAt / 1000),
|
|
47
|
+
caller: 'journal-nudger',
|
|
48
|
+
args,
|
|
49
|
+
input: prompt,
|
|
50
|
+
output,
|
|
51
|
+
durationMs,
|
|
84
52
|
});
|
|
53
|
+
return output;
|
|
85
54
|
}
|
|
86
55
|
function formatMsg(m) {
|
|
87
56
|
const who = m.direction === 'out' ? 'assistant' : m.pushName || m.senderNumber || 'user';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { runClaude, TIMEOUT_MS } from '../ai/spawn.js';
|
|
2
2
|
import { config } from '../config.js';
|
|
3
3
|
import { logger } from '../logger.js';
|
|
4
4
|
import { logPrompt } from '../promptlog.js';
|
|
@@ -20,64 +20,33 @@ async function spawnObserver(prompt) {
|
|
|
20
20
|
'--permission-mode',
|
|
21
21
|
'acceptEdits',
|
|
22
22
|
];
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
});
|
|
49
|
-
child.on('close', (code) => {
|
|
50
|
-
if (code !== 0) {
|
|
51
|
-
logFail(`exit ${code}: ${stderr.slice(0, 300)}`);
|
|
52
|
-
return rejectPromise(new Error(`journal observer exit ${code}: ${stderr.slice(0, 300)}`));
|
|
53
|
-
}
|
|
54
|
-
try {
|
|
55
|
-
const parsed = JSON.parse(stdout);
|
|
56
|
-
if (parsed.is_error ||
|
|
57
|
-
parsed.subtype !== 'success' ||
|
|
58
|
-
!parsed.result) {
|
|
59
|
-
logFail(`bad output: ${parsed.result ?? stderr.slice(0, 200)}`);
|
|
60
|
-
return rejectPromise(new Error(`journal observer bad output: ${parsed.result ?? stderr.slice(0, 200)}`));
|
|
61
|
-
}
|
|
62
|
-
const output = parsed.result.trim();
|
|
63
|
-
void logPrompt({
|
|
64
|
-
ts: Math.floor(startedAt / 1000),
|
|
65
|
-
caller: 'journal-observer',
|
|
66
|
-
args,
|
|
67
|
-
input: prompt,
|
|
68
|
-
output,
|
|
69
|
-
durationMs: Date.now() - startedAt,
|
|
70
|
-
});
|
|
71
|
-
resolvePromise(output);
|
|
72
|
-
}
|
|
73
|
-
catch (err) {
|
|
74
|
-
logFail(`parse failed: ${err.message}`);
|
|
75
|
-
rejectPromise(new Error(`journal observer parse failed: ${err.message}`));
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
child.stdin.write(prompt);
|
|
79
|
-
child.stdin.end();
|
|
23
|
+
const { stdout, durationMs } = await runClaude({
|
|
24
|
+
args,
|
|
25
|
+
input: prompt,
|
|
26
|
+
timeoutMs: TIMEOUT_MS.background,
|
|
27
|
+
caller: 'journal-observer',
|
|
28
|
+
});
|
|
29
|
+
const startedAt = Date.now() - durationMs;
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(stdout);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
throw new Error(`journal observer parse failed: ${err.message}`);
|
|
36
|
+
}
|
|
37
|
+
if (parsed.is_error || parsed.subtype !== 'success' || !parsed.result) {
|
|
38
|
+
throw new Error(`journal observer bad output: ${parsed.result ?? stdout.slice(0, 200)}`);
|
|
39
|
+
}
|
|
40
|
+
const output = parsed.result.trim();
|
|
41
|
+
void logPrompt({
|
|
42
|
+
ts: Math.floor(startedAt / 1000),
|
|
43
|
+
caller: 'journal-observer',
|
|
44
|
+
args,
|
|
45
|
+
input: prompt,
|
|
46
|
+
output,
|
|
47
|
+
durationMs,
|
|
80
48
|
});
|
|
49
|
+
return output;
|
|
81
50
|
}
|
|
82
51
|
function formatMsg(m) {
|
|
83
52
|
const date = new Date(m.timestamp * 1000)
|
package/dist/memory/preamble.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'fs';
|
|
|
2
2
|
import { resolve } from 'path';
|
|
3
3
|
import { config } from '../config.js';
|
|
4
4
|
import { listAsyncTasks } from '../queue/async-tasks.js';
|
|
5
|
+
import { readCompressed } from './compressed.js';
|
|
5
6
|
import { buildJournalsPreambleBlock, ensureJournalsScaffold, } from './journals.js';
|
|
6
7
|
import { masterIndexPath, treeIndexPath } from './paths.js';
|
|
7
8
|
import { routeIndexes } from './router.js';
|
|
@@ -75,6 +76,14 @@ export function buildMemoryPreamble(params) {
|
|
|
75
76
|
sections.push(`[Instruction]\n${DIGEST_REMINDER}`);
|
|
76
77
|
return sections.join('\n\n');
|
|
77
78
|
}
|
|
79
|
+
// Rolling state index: people + chats + buckets + active journals, 1-3
|
|
80
|
+
// lines each with path pointers. This is the primary memory surface.
|
|
81
|
+
// Tree indexes + routed entity indexes remain below as a secondary layer
|
|
82
|
+
// for Claude when the compressed view doesn't carry enough.
|
|
83
|
+
const compressed = readCompressed();
|
|
84
|
+
if (compressed) {
|
|
85
|
+
sections.push(`[State: current]\n${compressed.trim()}`);
|
|
86
|
+
}
|
|
78
87
|
// Full or self: load master + tree indexes
|
|
79
88
|
const master = readIfExists(masterIndexPath());
|
|
80
89
|
if (master)
|
package/dist/memory/scheduler.js
CHANGED
|
@@ -81,6 +81,17 @@ export function startScheduler() {
|
|
|
81
81
|
return;
|
|
82
82
|
ensureScaffold();
|
|
83
83
|
void prunePrompts(); // run once on boot
|
|
84
|
+
// Rebuild the compressed memory view on boot so every session starts with
|
|
85
|
+
// current state. Runs in background, don't block scheduler startup.
|
|
86
|
+
void (async () => {
|
|
87
|
+
try {
|
|
88
|
+
const { ensureCompressedFresh } = await import('./compressed.js');
|
|
89
|
+
await ensureCompressedFresh();
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
logger.error({ err }, 'compressed: boot rebuild failed');
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
84
95
|
sweepTimer = setInterval(() => {
|
|
85
96
|
void sweep().catch((err) => logger.error({ err }, 'sweep failed'));
|
|
86
97
|
}, config.memory.sweepIntervalMs);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { spawn } from 'child_process';
|
|
2
1
|
import { readFileSync } from 'fs';
|
|
3
2
|
import { resolve } from 'path';
|
|
3
|
+
import { runClaude, TIMEOUT_MS } from '../ai/spawn.js';
|
|
4
4
|
import { config } from '../config.js';
|
|
5
5
|
import fastq from 'fastq';
|
|
6
6
|
import { initiate } from '../gateway/outgoing.js';
|
|
@@ -118,64 +118,33 @@ function buildArgs(task) {
|
|
|
118
118
|
}
|
|
119
119
|
async function spawnClaudeForTask(task, prompt) {
|
|
120
120
|
const args = buildArgs(task);
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
});
|
|
147
|
-
child.on('close', (code) => {
|
|
148
|
-
if (code !== 0) {
|
|
149
|
-
logFail(`exit ${code}: ${stderr.slice(0, 300)}`);
|
|
150
|
-
return rejectPromise(new Error(`async task exit ${code}`));
|
|
151
|
-
}
|
|
152
|
-
try {
|
|
153
|
-
const parsed = JSON.parse(stdout);
|
|
154
|
-
if (parsed.is_error ||
|
|
155
|
-
parsed.subtype !== 'success' ||
|
|
156
|
-
!parsed.result) {
|
|
157
|
-
logFail(`bad output: ${parsed.result ?? stderr.slice(0, 200)}`);
|
|
158
|
-
return rejectPromise(new Error('async task bad output'));
|
|
159
|
-
}
|
|
160
|
-
const output = parsed.result.trim();
|
|
161
|
-
void logPrompt({
|
|
162
|
-
ts: Math.floor(startedAt / 1000),
|
|
163
|
-
caller: 'async-task',
|
|
164
|
-
args,
|
|
165
|
-
input: prompt,
|
|
166
|
-
output,
|
|
167
|
-
durationMs: Date.now() - startedAt,
|
|
168
|
-
});
|
|
169
|
-
resolvePromise(output);
|
|
170
|
-
}
|
|
171
|
-
catch (err) {
|
|
172
|
-
logFail(`parse failed: ${err.message}`);
|
|
173
|
-
rejectPromise(err);
|
|
174
|
-
}
|
|
175
|
-
});
|
|
176
|
-
child.stdin.write(prompt);
|
|
177
|
-
child.stdin.end();
|
|
121
|
+
const { stdout, durationMs } = await runClaude({
|
|
122
|
+
args,
|
|
123
|
+
input: prompt,
|
|
124
|
+
timeoutMs: TIMEOUT_MS.async,
|
|
125
|
+
caller: 'async-task',
|
|
126
|
+
});
|
|
127
|
+
const startedAt = Date.now() - durationMs;
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(stdout);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
throw new Error(`async task parse failed: ${err.message}`);
|
|
134
|
+
}
|
|
135
|
+
if (parsed.is_error || parsed.subtype !== 'success' || !parsed.result) {
|
|
136
|
+
throw new Error(`async task bad output: ${parsed.result ?? stdout.slice(0, 200)}`);
|
|
137
|
+
}
|
|
138
|
+
const output = parsed.result.trim();
|
|
139
|
+
void logPrompt({
|
|
140
|
+
ts: Math.floor(startedAt / 1000),
|
|
141
|
+
caller: 'async-task',
|
|
142
|
+
args,
|
|
143
|
+
input: prompt,
|
|
144
|
+
output,
|
|
145
|
+
durationMs,
|
|
178
146
|
});
|
|
147
|
+
return output;
|
|
179
148
|
}
|
|
180
149
|
async function runTask(task) {
|
|
181
150
|
const prompt = buildPrompt(task);
|
package/dist/queue/worker.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { askClaude } from '../ai/claude.js';
|
|
2
2
|
import { clearSession, setSession, setUsage } from '../ai/sessions.js';
|
|
3
|
+
import { config } from '../config.js';
|
|
3
4
|
import { logger } from '../logger.js';
|
|
4
5
|
import { extractFlags } from '../memory/digest-flag.js';
|
|
5
6
|
import { appendEntry, createJournal, getJournal, isValidSlug, } from '../memory/journals.js';
|
|
@@ -10,11 +11,14 @@ function isStaleSessionError(err) {
|
|
|
10
11
|
err.message.includes('No conversation found'));
|
|
11
12
|
}
|
|
12
13
|
async function callClaude(job) {
|
|
14
|
+
const startedAt = Date.now();
|
|
15
|
+
const wasFresh = !job.sessionId;
|
|
13
16
|
const { reply, sessionId, usage } = await askClaude({
|
|
14
17
|
input: job.input,
|
|
15
18
|
sessionId: job.sessionId,
|
|
16
19
|
allowedTools: job.allowedTools,
|
|
17
20
|
});
|
|
21
|
+
const durationMs = Date.now() - startedAt;
|
|
18
22
|
if (!job.sessionId) {
|
|
19
23
|
setSession(job.jid, sessionId);
|
|
20
24
|
}
|
|
@@ -83,7 +87,21 @@ async function callClaude(job) {
|
|
|
83
87
|
allowedTools: job.allowedTools ?? 'all',
|
|
84
88
|
});
|
|
85
89
|
}
|
|
86
|
-
return {
|
|
90
|
+
return {
|
|
91
|
+
reply: clean,
|
|
92
|
+
stats: {
|
|
93
|
+
durationMs,
|
|
94
|
+
inputTokens: usage.inputTokens,
|
|
95
|
+
outputTokens: usage.outputTokens,
|
|
96
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
97
|
+
totalContextTokens,
|
|
98
|
+
contextWindow: config.claude.contextWindow,
|
|
99
|
+
fresh: wasFresh,
|
|
100
|
+
hasDigest: digest !== null,
|
|
101
|
+
journalSlugs: journals.map((j) => j.slug),
|
|
102
|
+
asyncCount: asyncTasks.length,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
87
105
|
}
|
|
88
106
|
function titleCase(slug) {
|
|
89
107
|
return slug
|