@c4t4/heyamigo 0.3.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 +149 -67
- package/dist/config.js +1 -0
- package/dist/gateway/commands.js +4 -195
- package/dist/gateway/outgoing.js +59 -2
- package/dist/memory/compressed.js +334 -0
- package/dist/memory/digest-flag.js +15 -24
- package/dist/memory/digest.js +4 -0
- package/dist/memory/journals.js +0 -8
- package/dist/memory/preamble.js +30 -0
- package/dist/memory/scheduler.js +11 -0
- package/dist/queue/async-tasks.js +215 -0
- package/dist/queue/worker.js +49 -33
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -1,18 +1,21 @@
|
|
|
1
|
-
const TRAILING_TAG_RE = /\[(DIGEST|JOURNAL|JOURNAL-NEW|
|
|
1
|
+
const TRAILING_TAG_RE = /\[(DIGEST|JOURNAL|JOURNAL-NEW|ASYNC):\s*([^\]]+)\]\s*$/i;
|
|
2
2
|
// Peel trailing tags off the end of a reply. Supported:
|
|
3
3
|
// [DIGEST: <reason>]
|
|
4
4
|
// [JOURNAL:<slug> — <note>] (append entry)
|
|
5
5
|
// [JOURNAL-NEW:<slug> — <purpose>] (create journal)
|
|
6
|
-
// [
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// [ASYNC: <self-sufficient task description>]
|
|
7
|
+
// Multiple tags supported, any order at the tail. Tags must be the LAST
|
|
8
|
+
// thing in the reply (after trimming trailing whitespace).
|
|
9
|
+
//
|
|
10
|
+
// Journal pause/resume/archive is intentionally NOT a marker. If the owner
|
|
11
|
+
// wants those, Claude edits the journal's index.md frontmatter directly.
|
|
12
|
+
// Keeping the marker vocabulary small keeps Claude's context tight.
|
|
11
13
|
export function extractFlags(reply) {
|
|
12
14
|
let current = reply;
|
|
13
15
|
let digest = null;
|
|
14
16
|
const journals = [];
|
|
15
|
-
const
|
|
17
|
+
const journalCreates = [];
|
|
18
|
+
const asyncTasks = [];
|
|
16
19
|
while (true) {
|
|
17
20
|
const trimmed = current.replace(/\s+$/, '');
|
|
18
21
|
const match = trimmed.match(TRAILING_TAG_RE);
|
|
@@ -32,29 +35,17 @@ export function extractFlags(reply) {
|
|
|
32
35
|
else if (kind === 'JOURNAL-NEW') {
|
|
33
36
|
const parsed = parseJournalPayload(payload);
|
|
34
37
|
if (parsed) {
|
|
35
|
-
|
|
36
|
-
kind: 'new',
|
|
37
|
-
slug: parsed.slug,
|
|
38
|
-
purpose: parsed.note,
|
|
39
|
-
});
|
|
38
|
+
journalCreates.unshift({ slug: parsed.slug, purpose: parsed.note });
|
|
40
39
|
}
|
|
41
40
|
}
|
|
42
|
-
else if (kind === '
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const slug = payload.trim().toLowerCase();
|
|
46
|
-
if (/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
|
|
47
|
-
const op = kind === 'JOURNAL-PAUSE'
|
|
48
|
-
? 'pause'
|
|
49
|
-
: kind === 'JOURNAL-RESUME'
|
|
50
|
-
? 'resume'
|
|
51
|
-
: 'archive';
|
|
52
|
-
lifecycleOps.unshift({ kind: op, slug });
|
|
41
|
+
else if (kind === 'ASYNC') {
|
|
42
|
+
if (payload.length >= 8) {
|
|
43
|
+
asyncTasks.unshift({ description: payload });
|
|
53
44
|
}
|
|
54
45
|
}
|
|
55
46
|
current = trimmed.slice(0, match.index).trimEnd();
|
|
56
47
|
}
|
|
57
|
-
return { clean: current, digest, journals,
|
|
48
|
+
return { clean: current, digest, journals, journalCreates, asyncTasks };
|
|
58
49
|
}
|
|
59
50
|
// Legacy helper kept so existing callers still compile.
|
|
60
51
|
export function extractDigestFlag(reply) {
|
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/journals.js
CHANGED
|
@@ -304,11 +304,3 @@ export function saveNudgeState(slug, state) {
|
|
|
304
304
|
ensureDirFor(path);
|
|
305
305
|
writeFileSync(path, JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
|
306
306
|
}
|
|
307
|
-
export function snoozeJournal(slug, untilTs) {
|
|
308
|
-
if (!journalExists(slug))
|
|
309
|
-
return false;
|
|
310
|
-
const state = loadNudgeState(slug);
|
|
311
|
-
state.snoozedUntilTs = untilTs;
|
|
312
|
-
saveNudgeState(slug, state);
|
|
313
|
-
return true;
|
|
314
|
-
}
|
package/dist/memory/preamble.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs';
|
|
2
2
|
import { resolve } from 'path';
|
|
3
3
|
import { config } from '../config.js';
|
|
4
|
+
import { listAsyncTasks } from '../queue/async-tasks.js';
|
|
5
|
+
import { readCompressed } from './compressed.js';
|
|
4
6
|
import { buildJournalsPreambleBlock, ensureJournalsScaffold, } from './journals.js';
|
|
5
7
|
import { masterIndexPath, treeIndexPath } from './paths.js';
|
|
6
8
|
import { routeIndexes } from './router.js';
|
|
@@ -74,6 +76,14 @@ export function buildMemoryPreamble(params) {
|
|
|
74
76
|
sections.push(`[Instruction]\n${DIGEST_REMINDER}`);
|
|
75
77
|
return sections.join('\n\n');
|
|
76
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
|
+
}
|
|
77
87
|
// Full or self: load master + tree indexes
|
|
78
88
|
const master = readIfExists(masterIndexPath());
|
|
79
89
|
if (master)
|
|
@@ -119,6 +129,19 @@ export function buildMemoryPreamble(params) {
|
|
|
119
129
|
sections.push(`[Journals: active]\n${journalsBlock}`);
|
|
120
130
|
instructions.push(JOURNAL_REMINDER);
|
|
121
131
|
}
|
|
132
|
+
// Async tasks in progress for this chat — so Claude doesn't re-promise or
|
|
133
|
+
// contradict work already running in the background.
|
|
134
|
+
const asyncTasks = listAsyncTasks(params.jid);
|
|
135
|
+
if (asyncTasks.length > 0) {
|
|
136
|
+
const now = Math.floor(Date.now() / 1000);
|
|
137
|
+
const lines = ['You have background tasks currently running for this chat:'];
|
|
138
|
+
for (const t of asyncTasks) {
|
|
139
|
+
const ageSec = Math.max(0, now - t.startedAt);
|
|
140
|
+
lines.push(`- "${t.description}" (started ${formatAge(ageSec)} ago)`);
|
|
141
|
+
}
|
|
142
|
+
lines.push('', 'Do NOT re-start or re-promise these. Reply referencing that they are in progress if relevant, but do not emit another [ASYNC:...] for the same work.');
|
|
143
|
+
sections.push(`[Async tasks in progress]\n${lines.join('\n')}`);
|
|
144
|
+
}
|
|
122
145
|
sections.push(`[Instruction]\n${instructions.join('\n\n')}`);
|
|
123
146
|
return sections.join('\n\n');
|
|
124
147
|
}
|
|
@@ -127,6 +150,13 @@ function readIfExists(path) {
|
|
|
127
150
|
return null;
|
|
128
151
|
return readFileSync(path, 'utf-8');
|
|
129
152
|
}
|
|
153
|
+
function formatAge(seconds) {
|
|
154
|
+
if (seconds < 60)
|
|
155
|
+
return `${seconds}s`;
|
|
156
|
+
if (seconds < 3600)
|
|
157
|
+
return `${Math.floor(seconds / 60)}m`;
|
|
158
|
+
return `${Math.floor(seconds / 3600)}h${Math.floor((seconds % 3600) / 60)}m`;
|
|
159
|
+
}
|
|
130
160
|
function buildTimeLine(timezone) {
|
|
131
161
|
const now = new Date();
|
|
132
162
|
const fmt = new Intl.DateTimeFormat('en-GB', {
|
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);
|