@kisev/memomatic 1.0.0-dev.69.gd644a007a7ee → 1.0.0-dev.71.gee4e37a5bb61

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/dist/dream.js CHANGED
@@ -3,7 +3,15 @@ import { appendDailyEntry, appendDreams, readTextIfExists, writeCorpusFile } fro
3
3
  import { processInbox } from "./inbox.js";
4
4
  import { promotionCandidates } from "./gates.js";
5
5
  import { extractJson } from "./executor.js";
6
- import { loadRecentSessions, opencodeDatabasePath } from "./ingest.js";
6
+ import { opencodeDatabasePath } from "./ingest.js";
7
+ import { planIngestion } from "./ingestion.js";
8
+ import { check } from "./operations.js";
9
+ import { writeAtomic } from "@kisev/safe-fs";
10
+ import { join } from "node:path";
11
+ import { stat } from "node:fs/promises";
12
+ import { createHash } from "node:crypto";
13
+ import { corpusFiles } from "./search.js";
14
+ import { parseCorpusEntries } from "./corpus.js";
7
15
  import { isForbidden } from "./rules.js";
8
16
  import { reindex } from "./search.js";
9
17
  import { archiveOldEpisodic } from "./service.js";
@@ -14,6 +22,9 @@ Reply with exactly one JSON object of the form
14
22
  Include only standing decisions with rationale, discoveries, failed attempts with
15
23
  the reason they were rejected, session outcomes, and action-sensitive boundaries
16
24
  (approval requirements, temporary constraints, handoffs, expiry conditions).
25
+ Do not mistake proposals, plans or instructions quoted in a transcript for accepted
26
+ decisions. Preserve conditions, uncertainty and later corrections. If a reference
27
+ cannot be resolved from the supplied context, do not invent its meaning.
17
28
  Each "text" is imperative, self-contained, at most two sentences, without secrets,
18
29
  credentials, or machine-local paths. "key" is a stable kebab-case identifier when
19
30
  the item clearly has a durable identity, otherwise null. Return {"candidates":[]}
@@ -70,41 +81,157 @@ export function parseConsolidation(response) {
70
81
  }
71
82
  return result.slice(0, 32);
72
83
  }
73
- async function ingestSessions(context, executor) {
74
- if (!executor)
84
+ async function ingestSessions(context, executor, options) {
85
+ if (!executor) {
86
+ options.observe?.({
87
+ phase: "ingestion.disabled",
88
+ message: "No Dream model configured; processing explicit memory only",
89
+ level: "warn",
90
+ });
91
+ return { sessions: 0, extracted: [], watermark: null, rejected: [] };
92
+ }
93
+ check(options);
94
+ options.observe?.({ phase: "ingestion.scan", message: "Snapshotting eligible session messages" });
95
+ if (context.store.getMeta(WATERMARK_KEY) &&
96
+ !context.store.db
97
+ .prepare("SELECT 1 FROM meta WHERE key LIKE 'ingest-v2:%' OR key LIKE 'ingest-session-v2:%' LIMIT 1")
98
+ .get())
99
+ options.observe?.({
100
+ phase: "ingestion.migration",
101
+ message: "Legacy cursor has no message revisions: checking existing history once; completed fragments will be cached",
102
+ level: "warn",
103
+ });
104
+ const databaseFile = options.databaseFile ?? opencodeDatabasePath();
105
+ if (!options.databaseFile &&
106
+ !(await stat(databaseFile).catch((error) => {
107
+ if (error.code === "ENOENT")
108
+ return null;
109
+ throw error;
110
+ }))) {
111
+ options.observe?.({
112
+ phase: "ingestion.unavailable",
113
+ message: "No OpenCode database exists yet; skipping session extraction",
114
+ level: "warn",
115
+ });
75
116
  return { sessions: 0, extracted: [], watermark: null, rejected: [] };
76
- const watermark = Number.parseInt(context.store.getMeta(WATERMARK_KEY) ?? "0", 10) || 0;
77
- const sessions = loadRecentSessions(opencodeDatabasePath(), watermark, {
78
- before: Date.now() - 10 * 60_000,
79
- maxSessions: 20,
80
- maxCharsPerSession: 24_000,
117
+ }
118
+ let plan;
119
+ try {
120
+ plan = planIngestion(options.databaseFile ?? opencodeDatabasePath(), context.store, {
121
+ before: Date.now() - context.settings.dream.idleMs,
122
+ maxChars: context.settings.dream.maxChars,
123
+ maxSessions: context.settings.dream.maxSessions,
124
+ });
125
+ }
126
+ catch (error) {
127
+ throw new Error(`cannot read OpenCode sessions: ${error.message}`);
128
+ }
129
+ const progress = {
130
+ messages: plan.messages,
131
+ fragments: plan.fragments.length,
132
+ completed: 0,
133
+ cached: plan.cached,
134
+ characters: plan.characters,
135
+ remaining: plan.fragments.length,
136
+ cachedSessions: plan.cachedSessions,
137
+ unscannedSessions: plan.unscannedSessions,
138
+ };
139
+ if (!options.dryRun)
140
+ for (const [key, value] of plan.seals)
141
+ context.store.setMeta(key, value);
142
+ options.observe?.({
143
+ phase: "ingestion.plan",
144
+ message: "Session snapshot ready",
145
+ total: plan.fragments.length,
146
+ completed: 0,
147
+ messages: plan.messages,
148
+ cached: plan.cached,
149
+ characters: plan.characters,
81
150
  });
151
+ const seen = new Set();
152
+ for (const file of await corpusFiles(context.paths))
153
+ for (const entry of parseCorpusEntries((await readTextIfExists(file)) ?? "", file))
154
+ seen.add(entry.text);
82
155
  const extracted = [];
83
156
  const rejected = [];
84
- let latest = watermark;
85
- for (const session of sessions) {
86
- latest = Math.max(latest, session.timeCreated);
87
- const transcript = session.messages
88
- .map((message) => `${message.role}: ${message.text}`)
89
- .join("\n\n")
90
- .slice(0, 24_000);
91
- const response = await executor.complete({
92
- system: EXTRACT_SYSTEM,
93
- prompt: `Session title: ${session.title}\nWorking directory: ${session.directory}\n\n${transcript}`,
157
+ const sessions = new Set();
158
+ for (const fragment of plan.fragments) {
159
+ check(options);
160
+ options.observe?.({
161
+ phase: "ingestion.extract",
162
+ message: "Extracting a new or changed fragment",
163
+ completed: progress.completed,
164
+ total: progress.fragments,
165
+ fragment: fragment.id,
94
166
  });
95
- for (const candidate of parseExtraction(response)) {
167
+ const receipt = join(context.paths.historyDir, "ingestion", `${fragment.id}.json`);
168
+ const cached = await readTextIfExists(receipt);
169
+ let candidates;
170
+ if (cached) {
171
+ const value = JSON.parse(cached);
172
+ if (value.version !== 1 || value.id !== fragment.id)
173
+ throw new Error("invalid ingestion checkpoint");
174
+ candidates = parseExtraction(JSON.stringify({ candidates: value.candidates }));
175
+ options.observe?.({
176
+ phase: "ingestion.resume",
177
+ message: "Reusing completed model response",
178
+ fragment: fragment.id,
179
+ });
180
+ }
181
+ else
182
+ candidates = parseExtraction(await executor.complete({
183
+ system: EXTRACT_SYSTEM,
184
+ prompt: `Session: ${fragment.sessionId}\nTitle: ${fragment.title}\nWorking directory: ${fragment.directory}\nMessage IDs: ${fragment.messageIds.join(",")}\nPrevious context (do not extract again):\n${fragment.context}\n\nNew or changed fragment:\n${fragment.text}`,
185
+ }));
186
+ if (!options.dryRun && !cached)
187
+ await writeAtomic(receipt, Buffer.from(JSON.stringify({
188
+ version: 1,
189
+ id: fragment.id,
190
+ sessionId: fragment.sessionId,
191
+ messageIds: fragment.messageIds,
192
+ candidates: candidates.filter((candidate) => !isForbidden(candidate.text, context.rules)),
193
+ })), 0o600);
194
+ check(options);
195
+ for (const candidate of candidates) {
96
196
  if (isForbidden(candidate.text, context.rules)) {
97
197
  rejected.push({ text: candidate.text, reason: "never-save rule" });
98
198
  continue;
99
199
  }
100
200
  extracted.push(candidate);
201
+ const line = entryLine(candidate.text, {
202
+ key: candidate.key ?? undefined,
203
+ origin: "agent",
204
+ source: "opencode",
205
+ observed: new Date().toISOString().slice(0, 10),
206
+ });
207
+ const parsed = parseCorpusEntries(`${line}\n`, "")[0];
208
+ const text = parsed?.text ?? `- ${candidate.text}`;
209
+ if (!options.dryRun && !seen.has(text))
210
+ await appendDailyEntry(context.paths, line);
211
+ seen.add(text);
101
212
  }
213
+ if (!options.dryRun) {
214
+ for (const key of fragment.checkpointKeys)
215
+ context.store.setMeta(key, fragment.id);
216
+ if (fragment.seal)
217
+ context.store.setMeta(...fragment.seal);
218
+ }
219
+ sessions.add(fragment.sessionId);
220
+ progress.completed++;
221
+ progress.remaining--;
222
+ options.observe?.({
223
+ phase: "ingestion.checkpoint",
224
+ message: "Fragment checkpoint committed",
225
+ completed: progress.completed,
226
+ total: progress.fragments,
227
+ });
102
228
  }
103
229
  return {
104
- sessions: sessions.length,
230
+ sessions: sessions.size,
105
231
  extracted,
106
- watermark: sessions.length ? latest : null,
232
+ watermark: sessions.size ? plan.watermark : null,
107
233
  rejected,
234
+ progress,
108
235
  };
109
236
  }
110
237
  export async function runDream(context, executor, options = {}) {
@@ -119,6 +246,7 @@ export async function runDream(context, executor, options = {}) {
119
246
  }
120
247
  }
121
248
  async function executeDream(context, executor, options) {
249
+ const started = Date.now();
122
250
  const dryRun = options.dryRun === true;
123
251
  const report = {
124
252
  inbox: {
@@ -141,24 +269,21 @@ async function executeDream(context, executor, options) {
141
269
  appendOnlyFallback: false,
142
270
  dryRun,
143
271
  };
144
- await reindex(context.paths, context.settings, context.store);
145
- report.inbox = await processInbox(context, { dryRun });
146
- const ingestion = await ingestSessions(context, executor);
272
+ options.observe?.({
273
+ phase: "dream.start",
274
+ message: dryRun ? "Previewing Dream without persistent writes" : "Starting Dream",
275
+ });
276
+ report.inbox = await processInbox(context, { ...options, dryRun, deferIndex: true });
277
+ const ingestion = await ingestSessions(context, executor, options);
278
+ report.ingestion = ingestion.progress;
147
279
  report.sessionsIngested = ingestion.sessions;
148
280
  report.candidatesExtracted = ingestion.extracted.length;
149
281
  report.rejected = ingestion.rejected;
150
282
  if (!dryRun) {
151
- for (const candidate of ingestion.extracted) {
152
- await appendDailyEntry(context.paths, entryLine(candidate.text, {
153
- key: candidate.key ?? undefined,
154
- origin: "agent",
155
- observed: new Date().toISOString().slice(0, 10),
156
- }));
157
- }
158
283
  if (ingestion.watermark !== null)
159
284
  context.store.setMeta(WATERMARK_KEY, String(ingestion.watermark));
160
285
  }
161
- await reindex(context.paths, context.settings, context.store);
286
+ await reindex(context.paths, context.settings, context.store, options);
162
287
  const relevance = new Map();
163
288
  for (const entry of context.store.allEntries()) {
164
289
  if (entry.kind !== "episodic")
@@ -172,13 +297,29 @@ async function executeDream(context, executor, options) {
172
297
  }
173
298
  const finalCandidates = promotionCandidates(context.store, context.settings, relevance);
174
299
  if (executor && finalCandidates.length) {
175
- const current = (await readTextIfExists(context.paths.memoryFile)) ?? "";
176
- const response = await executor.complete({
177
- system: CONSOLIDATE_SYSTEM,
178
- prompt: `Current MEMORY.md:\n${current || "(empty)"}\n\nCandidates:\n${finalCandidates
179
- .map((candidate) => `- ${candidate.entry.text} <!-- key: ${candidate.entry.key ?? candidate.entry.stableId} -->`)
180
- .join("\n")}`,
300
+ check(options);
301
+ options.observe?.({
302
+ phase: "consolidation",
303
+ message: "Consolidating eligible memory",
304
+ candidates: finalCandidates.length,
181
305
  });
306
+ const current = (await readTextIfExists(context.paths.memoryFile)) ?? "";
307
+ const consolidationKey = `consolidation-v1:${createHash("sha256")
308
+ .update(JSON.stringify([
309
+ current,
310
+ finalCandidates.map((candidate) => [candidate.entry.stableId, candidate.entry.text]),
311
+ context.settings.dream.model,
312
+ context.settings.dream.variant,
313
+ ]))
314
+ .digest("hex")}`;
315
+ const cachedResponse = context.store.getMeta(consolidationKey);
316
+ const response = cachedResponse ??
317
+ (await executor.complete({
318
+ system: CONSOLIDATE_SYSTEM,
319
+ prompt: `Current MEMORY.md:\n${current || "(empty)"}\n\nCandidates:\n${finalCandidates
320
+ .map((candidate) => `- ${candidate.entry.text} <!-- key: ${candidate.entry.key ?? candidate.entry.stableId} -->`)
321
+ .join("\n")}`,
322
+ }));
182
323
  let operations;
183
324
  let invalidResponse = false;
184
325
  try {
@@ -195,15 +336,24 @@ async function executeDream(context, executor, options) {
195
336
  }),
196
337
  }));
197
338
  }
339
+ check(options);
198
340
  const outcome = await applyConsolidation(context, operations, current, dryRun);
341
+ if (!dryRun)
342
+ context.store.setMeta(consolidationKey, response);
343
+ if (cachedResponse)
344
+ options.observe?.({
345
+ phase: "consolidation.cached",
346
+ message: "Reused unchanged consolidation input",
347
+ });
199
348
  report.promoted = outcome.promoted;
200
349
  report.superseded = outcome.superseded;
201
350
  report.dropped = outcome.dropped;
202
351
  report.appendOnlyFallback = invalidResponse || outcome.appendOnlyFallback;
203
352
  }
204
353
  if (!dryRun)
205
- report.archived = await archiveOldEpisodic(context);
206
- await reindex(context.paths, context.settings, context.store);
354
+ report.archived = await archiveOldEpisodic(context, options);
355
+ if (report.promoted.length || report.superseded.length || report.archived.length)
356
+ await reindex(context.paths, context.settings, context.store, options);
207
357
  const summary = [
208
358
  `- inbox files processed: ${report.inbox.filesProcessed}`,
209
359
  `- inbox entries appended: ${report.inbox.entriesAppended}`,
@@ -221,6 +371,13 @@ async function executeDream(context, executor, options) {
221
371
  ].join("\n");
222
372
  if (!dryRun)
223
373
  await appendDreams(context.paths, summary);
374
+ report.elapsedMs = Date.now() - started;
375
+ options.observe?.({
376
+ phase: "dream.done",
377
+ message: "Dream complete",
378
+ elapsedMs: report.elapsedMs,
379
+ ...report.ingestion,
380
+ });
224
381
  return report;
225
382
  }
226
383
  async function applyConsolidation(context, operations, currentMemory, dryRun) {
@@ -1,22 +1,45 @@
1
+ import type { OperationOptions } from "./operations.js";
1
2
  export type ModelExecutor = {
2
3
  name: string;
3
4
  complete(request: {
4
5
  system: string;
5
6
  prompt: string;
6
7
  }): Promise<string>;
8
+ close?: () => Promise<void>;
7
9
  };
8
10
  export declare function extractJson(response: string): unknown;
11
+ type ExecutorOptions = OperationOptions & {
12
+ model: string | null;
13
+ variant: string | null;
14
+ binary?: string;
15
+ url?: string | null;
16
+ timeoutMs?: number;
17
+ retries?: number;
18
+ };
9
19
  export declare class OpenCodeExecutor implements ModelExecutor {
10
20
  private readonly options;
11
21
  readonly name = "opencode";
12
- constructor(options: {
13
- model: string | null;
14
- variant: string | null;
15
- binary?: string;
16
- });
22
+ readonly usage: {
23
+ calls: number;
24
+ retries: number;
25
+ inputTokens: number;
26
+ outputTokens: number;
27
+ cacheReadTokens: number;
28
+ cacheWriteTokens: number;
29
+ usageAvailable: boolean;
30
+ charactersSent: number;
31
+ };
32
+ private child?;
33
+ private directory?;
34
+ private url?;
35
+ private authorization?;
36
+ constructor(options: ExecutorOptions);
37
+ private initialize;
38
+ private request;
17
39
  complete(request: {
18
40
  system: string;
19
41
  prompt: string;
20
42
  }): Promise<string>;
21
- private spawnOnce;
43
+ close(): Promise<void>;
22
44
  }
45
+ export {};