@c4t4/heyamigo 0.5.0 → 0.6.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/config/config.example.json +2 -1
- package/config/memory-instructions.md +22 -0
- package/dist/config.js +1 -0
- package/dist/gateway/outgoing.js +59 -2
- package/dist/memory/compressed.js +334 -0
- package/dist/memory/digest.js +4 -0
- package/dist/memory/preamble.js +9 -0
- package/dist/memory/scheduler.js +11 -0
- 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/config.js
CHANGED
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,334 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, } from 'fs';
|
|
3
|
+
import { dirname, resolve } from 'path';
|
|
4
|
+
import { mkdirSync } from 'fs';
|
|
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 startedAt = Date.now();
|
|
234
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
235
|
+
const child = spawn('claude', args, {
|
|
236
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
237
|
+
cwd: process.cwd(),
|
|
238
|
+
});
|
|
239
|
+
let stdout = '';
|
|
240
|
+
let stderr = '';
|
|
241
|
+
child.stdout.on('data', (c) => {
|
|
242
|
+
stdout += c.toString('utf-8');
|
|
243
|
+
});
|
|
244
|
+
child.stderr.on('data', (c) => {
|
|
245
|
+
stderr += c.toString('utf-8');
|
|
246
|
+
});
|
|
247
|
+
const logFail = (error) => void logPrompt({
|
|
248
|
+
ts: Math.floor(startedAt / 1000),
|
|
249
|
+
caller: 'compressed',
|
|
250
|
+
args,
|
|
251
|
+
input: prompt,
|
|
252
|
+
error,
|
|
253
|
+
durationMs: Date.now() - startedAt,
|
|
254
|
+
});
|
|
255
|
+
child.on('error', (err) => {
|
|
256
|
+
logFail(`spawn failed: ${err.message}`);
|
|
257
|
+
rejectPromise(err);
|
|
258
|
+
});
|
|
259
|
+
child.on('close', (code) => {
|
|
260
|
+
if (code !== 0) {
|
|
261
|
+
logFail(`exit ${code}: ${stderr.slice(0, 300)}`);
|
|
262
|
+
return rejectPromise(new Error(`compressed generator exit ${code}`));
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const parsed = JSON.parse(stdout);
|
|
266
|
+
if (parsed.is_error ||
|
|
267
|
+
parsed.subtype !== 'success' ||
|
|
268
|
+
!parsed.result) {
|
|
269
|
+
logFail(`bad output: ${parsed.result ?? stderr.slice(0, 200)}`);
|
|
270
|
+
return rejectPromise(new Error('compressed generator bad output'));
|
|
271
|
+
}
|
|
272
|
+
const output = parsed.result.trim();
|
|
273
|
+
void logPrompt({
|
|
274
|
+
ts: Math.floor(startedAt / 1000),
|
|
275
|
+
caller: 'compressed',
|
|
276
|
+
args,
|
|
277
|
+
input: prompt,
|
|
278
|
+
output,
|
|
279
|
+
durationMs: Date.now() - startedAt,
|
|
280
|
+
});
|
|
281
|
+
resolvePromise(output);
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
logFail(`parse failed: ${err.message}`);
|
|
285
|
+
rejectPromise(err);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
child.stdin.write(prompt);
|
|
289
|
+
child.stdin.end();
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
let buildInFlight = null;
|
|
293
|
+
export async function rebuildCompressed() {
|
|
294
|
+
if (buildInFlight)
|
|
295
|
+
return buildInFlight;
|
|
296
|
+
buildInFlight = (async () => {
|
|
297
|
+
const raw = buildInputForGenerator();
|
|
298
|
+
if (raw === '(no memory yet)') {
|
|
299
|
+
const empty = '# State: current\n\n(no memory yet)\n';
|
|
300
|
+
const path = compressedPath();
|
|
301
|
+
ensureDirFor(path);
|
|
302
|
+
writeFileSync(path, empty, 'utf-8');
|
|
303
|
+
saveState({ lastBuiltAt: Math.floor(Date.now() / 1000), dirty: false });
|
|
304
|
+
logger.info('compressed: empty scaffold written (no memory yet)');
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const prompt = generatorPrompt(raw);
|
|
308
|
+
const output = await spawnGenerator(prompt);
|
|
309
|
+
const path = compressedPath();
|
|
310
|
+
ensureDirFor(path);
|
|
311
|
+
writeFileSync(path, output + '\n', 'utf-8');
|
|
312
|
+
saveState({ lastBuiltAt: Math.floor(Date.now() / 1000), dirty: false });
|
|
313
|
+
logger.info({ chars: output.length }, 'compressed: rebuilt');
|
|
314
|
+
})();
|
|
315
|
+
try {
|
|
316
|
+
await buildInFlight;
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
buildInFlight = null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Regenerate only if dirty or missing. Used in boot + lazy access paths.
|
|
323
|
+
export async function ensureCompressedFresh() {
|
|
324
|
+
const state = loadState();
|
|
325
|
+
const exists = existsSync(compressedPath());
|
|
326
|
+
if (!state.dirty && exists)
|
|
327
|
+
return;
|
|
328
|
+
try {
|
|
329
|
+
await rebuildCompressed();
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
logger.error({ err }, 'compressed: rebuild failed');
|
|
333
|
+
}
|
|
334
|
+
}
|
package/dist/memory/digest.js
CHANGED
|
@@ -3,6 +3,7 @@ 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.
|
|
@@ -199,4 +200,7 @@ export async function runDigest(params) {
|
|
|
199
200
|
logger.error({ err, number }, 'profile digest failed');
|
|
200
201
|
}
|
|
201
202
|
}
|
|
203
|
+
// A profile or brief changed. Mark compressed view dirty so the next
|
|
204
|
+
// session boot or ensureCompressedFresh() call regenerates it.
|
|
205
|
+
markCompressedDirty();
|
|
202
206
|
}
|
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);
|
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
|