@rol1n/memory-log 0.0.0-stage → 0.1.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.
Files changed (37) hide show
  1. package/README.md +53 -2
  2. package/extensions/memory-log/README.md +54 -0
  3. package/extensions/memory-log/agent/consolidator/prompt.ts +47 -0
  4. package/extensions/memory-log/agent/consolidator/tools.ts +160 -0
  5. package/extensions/memory-log/agent/cost.ts +32 -0
  6. package/extensions/memory-log/agent/index.ts +42 -0
  7. package/extensions/memory-log/agent/observer/prompt.ts +73 -0
  8. package/extensions/memory-log/agent/observer/tool.ts +73 -0
  9. package/extensions/memory-log/commands/compact.ts +35 -0
  10. package/extensions/memory-log/commands/consolidate.ts +47 -0
  11. package/extensions/memory-log/commands/status.ts +50 -0
  12. package/extensions/memory-log/config.ts +253 -0
  13. package/extensions/memory-log/debug-log.ts +76 -0
  14. package/extensions/memory-log/hooks/compaction-hook.ts +161 -0
  15. package/extensions/memory-log/hooks/compaction-trigger.ts +160 -0
  16. package/extensions/memory-log/hooks/consolidator-trigger.ts +143 -0
  17. package/extensions/memory-log/hooks/observer-trigger.ts +176 -0
  18. package/extensions/memory-log/ids.ts +78 -0
  19. package/extensions/memory-log/index.ts +149 -0
  20. package/extensions/memory-log/ledger/fold.ts +77 -0
  21. package/extensions/memory-log/ledger/index.ts +7 -0
  22. package/extensions/memory-log/ledger/pool.ts +38 -0
  23. package/extensions/memory-log/ledger/progress.ts +181 -0
  24. package/extensions/memory-log/ledger/projection.ts +115 -0
  25. package/extensions/memory-log/ledger/render.ts +38 -0
  26. package/extensions/memory-log/ledger/serialize.ts +161 -0
  27. package/extensions/memory-log/ledger/types.ts +201 -0
  28. package/extensions/memory-log/memory/index-render.ts +45 -0
  29. package/extensions/memory-log/memory/paths.ts +120 -0
  30. package/extensions/memory-log/memory/session.ts +64 -0
  31. package/extensions/memory-log/runtime.ts +127 -0
  32. package/extensions/memory-log/spawn/launch.ts +133 -0
  33. package/extensions/memory-log/spawn/runs.ts +83 -0
  34. package/extensions/memory-log/tokens.ts +66 -0
  35. package/extensions/memory-log/ui/status-controller.ts +225 -0
  36. package/extensions/memory-log/ui/timeline.ts +103 -0
  37. package/package.json +41 -4
@@ -0,0 +1,50 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { foldLedger, poolTokens, rawTokensSinceObservationCoverage, sumSessionCost, type Entry } from "../ledger/index.ts";
3
+ import { listTopics, readJourney } from "../memory/paths.ts";
4
+ import { estimateStringTokens } from "../tokens.ts";
5
+ import type { Runtime } from "../runtime.ts";
6
+ import { renderTimeline } from "../ui/timeline.ts";
7
+
8
+ export async function handleStatusCommand(_args: string, ctx: ExtensionContext, runtime: Runtime): Promise<void> {
9
+ if (!ctx.hasUI) return;
10
+ if (!runtime.enabled) {
11
+ ctx.ui.notify("memory-log is disabled (run /mem-log on to enable)", "info");
12
+ return;
13
+ }
14
+ runtime.ensureConfig(ctx.cwd);
15
+ const branch = ctx.sessionManager.getBranch() as Entry[];
16
+ const folded = foldLedger(branch);
17
+ const sinceObservation = rawTokensSinceObservationCoverage(branch);
18
+ const contextTokens = ctx.getContextUsage?.()?.tokens ?? null;
19
+ const pool = poolTokens(folded.activeObservations);
20
+ const topicCount = listTopics(runtime.memoryRoot).length;
21
+ const journey = readJourney(runtime.memoryRoot);
22
+ const { costUsd, runs } = sumSessionCost(ctx.sessionManager.getEntries() as Entry[]);
23
+
24
+ const lines = [
25
+ `─── Observational Memory Log Status ───`,
26
+ ` • Active observers : ${runtime.observersInFlight.size} / ${runtime.config.observerConcurrency}`,
27
+ ` • Active observations : ${folded.activeObservations.length}`,
28
+ ` • Next chunk progress : ${sinceObservation.toLocaleString()} / ${runtime.config.chunkTokens.toLocaleString()} tok`,
29
+ ` • Active observation pool: ${pool.toLocaleString()} tok (target: ${runtime.config.poolTargetTokens.toLocaleString()}, trigger: ${runtime.config.consolidateAtPoolTokens.toLocaleString()})`,
30
+ ` • Consolidator status : ${runtime.consolidatorInFlight ? "running" : "idle"}` ,
31
+ ` • Compaction wait status : ${runtime.lastCompactionObserverWait ?? "n/a"}`,
32
+ ` • Topic files count : ${topicCount} (.memory/<sessionId>/)`,
33
+ ` • Journey history : ${journey ? `~${estimateStringTokens(journey).toLocaleString()} / ${runtime.config.journeyTargetTokens.toLocaleString()} tok` : "none"}`,
34
+ ` • Context window usage : ${contextTokens != null ? contextTokens.toLocaleString() : "?"} / ${runtime.config.compactAtContextTokens.toLocaleString()} tok`,
35
+ ` • Session total cost : $${costUsd.toFixed(4)} (${runs} run${runs === 1 ? "" : "s"})`,
36
+ ` • Last worker error : ${runtime.lastWorkerError || "none"}`,
37
+ ``,
38
+ renderTimeline(branch, runtime.config),
39
+ ];
40
+ ctx.ui.notify(lines.join("\n"), "info");
41
+ }
42
+
43
+ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void {
44
+ const handler = async (args: string, ctx: ExtensionContext) => handleStatusCommand(args, ctx, runtime);
45
+
46
+ pi.registerCommand("mem-log:status", {
47
+ description: "View observational memory log running status",
48
+ handler,
49
+ });
50
+ }
@@ -0,0 +1,253 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+
6
+ const CONFIG_DIR_NAME = ".pi";
7
+
8
+ export interface ConfiguredModel {
9
+ provider?: string;
10
+ id?: string;
11
+ thinking?: ModelThinkingLevel;
12
+ }
13
+
14
+ export interface Config {
15
+ /** Whether new sessions start with observational memory enabled. */
16
+ defaultEnabled: boolean;
17
+ /** Raw-history token size of one observation chunk (fixed boundary). */
18
+ chunkTokens: number;
19
+ /** Overlap between adjacent chunks; default 0 in v1. */
20
+ chunkOverlapTokens: number;
21
+ /** Target size of the active observation pool; the buffer drains back toward this after consolidation. */
22
+ poolTargetTokens: number;
23
+ /** Active-pool token count that triggers a consolidation (150% - 200% of target). */
24
+ consolidateAtPoolTokens: number;
25
+ /** Live context-window usage that triggers compaction. */
26
+ compactAtContextTokens: number;
27
+ /** Verbatim raw tail kept after the cutoff; snaps to a chunk boundary. */
28
+ tailTokens: number;
29
+ /**
30
+ * Target size of `.memory/JOURNEY.md`, the running descriptive project history the
31
+ * consolidator appends to and pushes into every compaction block.
32
+ */
33
+ journeyTargetTokens: number;
34
+ /** Max simultaneous in-flight observer subprocesses. */
35
+ observerConcurrency: number;
36
+ models: {
37
+ observer: ConfiguredModel;
38
+ consolidator: ConfiguredModel;
39
+ };
40
+ /**
41
+ * Pi settings default model (`defaultProvider`/`defaultModel` from settings.json),
42
+ * used as the last-resort fallback when no session model is available.
43
+ */
44
+ settingsDefaultModel?: { provider?: string; id?: string };
45
+ /** Resume the agent automatically after a mid-run compaction. */
46
+ resumeAfterMidRunCompaction: boolean;
47
+ /** Power-user setting: disable all triggers (distinct from the on/off gate). */
48
+ passive: boolean;
49
+ /** Emit the NDJSON debug log. */
50
+ debugLog: boolean;
51
+ }
52
+
53
+ export const DEFAULTS: Config = {
54
+ defaultEnabled: false,
55
+ chunkTokens: 10_000,
56
+ chunkOverlapTokens: 0,
57
+ poolTargetTokens: 10_000,
58
+ consolidateAtPoolTokens: 15_000,
59
+ compactAtContextTokens: 150_000,
60
+ tailTokens: 20_000,
61
+ journeyTargetTokens: 1_000,
62
+ observerConcurrency: 4,
63
+ resumeAfterMidRunCompaction: true,
64
+ models: {
65
+ observer: { thinking: "low" },
66
+ consolidator: { thinking: "medium" },
67
+ },
68
+ passive: false,
69
+ debugLog: false,
70
+ };
71
+
72
+ const THINKING_LEVEL_VALUES: readonly ModelThinkingLevel[] = [
73
+ "off",
74
+ "minimal",
75
+ "low",
76
+ "medium",
77
+ "high",
78
+ "xhigh",
79
+ ] as const;
80
+
81
+ const SETTINGS_KEYS = ["memoryLog", "memory-log", "memLog", "mem-log", "srpMemory", "srp-memory", "observational-memory"] as const;
82
+ const PASSIVE_ENV = "PI_OM_PASSIVE";
83
+
84
+ function positiveIntegerOrUndefined(value: unknown): number | undefined {
85
+ return Number.isInteger(value) && typeof value === "number" && value > 0 ? value : undefined;
86
+ }
87
+
88
+ function isThinkingLevel(value: unknown): value is ModelThinkingLevel {
89
+ return typeof value === "string" && (THINKING_LEVEL_VALUES as readonly string[]).includes(value);
90
+ }
91
+
92
+ function isRecord(value: unknown): value is Record<string, unknown> {
93
+ return typeof value === "object" && value !== null && !Array.isArray(value);
94
+ }
95
+
96
+ function nonEmptyString(value: unknown): string | undefined {
97
+ return typeof value === "string" && value.length > 0 ? value : undefined;
98
+ }
99
+
100
+ function normalizeModel(value: unknown, fallback: ConfiguredModel): ConfiguredModel {
101
+ if (!isRecord(value)) return fallback;
102
+ const provider = nonEmptyString(value.provider) ?? fallback.provider;
103
+ const id = nonEmptyString(value.id) ?? fallback.id;
104
+ const model: ConfiguredModel = {};
105
+ if (provider) model.provider = provider;
106
+ if (id) model.id = id;
107
+ const thinking = isThinkingLevel(value.thinking) ? value.thinking : fallback.thinking;
108
+ if (thinking) model.thinking = thinking;
109
+ return model;
110
+ }
111
+
112
+ /**
113
+ * Resolve the model a worker subprocess should run on.
114
+ *
115
+ * Priority: memory-log configured model > current session model > pi settings
116
+ * default model. When nothing resolves, return an empty model so the worker
117
+ * omits `--provider`/`--model` and pi resolves its own default — never pin a
118
+ * hardcoded model here.
119
+ */
120
+ export function resolveEffectiveModel(
121
+ configured?: ConfiguredModel,
122
+ sessionModel?: { provider?: string; id?: string },
123
+ settingsDefault?: { provider?: string; id?: string },
124
+ ): ConfiguredModel {
125
+ const model: ConfiguredModel = {};
126
+ const provider = configured?.provider || sessionModel?.provider || settingsDefault?.provider;
127
+ const id = configured?.id || sessionModel?.id || settingsDefault?.id;
128
+ if (provider) model.provider = provider;
129
+ if (id) model.id = id;
130
+ if (configured?.thinking) model.thinking = configured.thinking;
131
+ return model;
132
+ }
133
+
134
+ function normalizeSettingsConfig(value: Record<string, unknown>, base: Config): Partial<Config> {
135
+ const normalized: Partial<Config> = {};
136
+ if (typeof value.default_enabled === "boolean") {
137
+ normalized.defaultEnabled = value.default_enabled;
138
+ }
139
+ if (typeof value.defaultEnabled === "boolean") {
140
+ normalized.defaultEnabled = value.defaultEnabled;
141
+ }
142
+ const numberKeys = [
143
+ "chunkTokens",
144
+ "chunkOverlapTokens",
145
+ "poolTargetTokens",
146
+ "consolidateAtPoolTokens",
147
+ "compactAtContextTokens",
148
+ "tailTokens",
149
+ "journeyTargetTokens",
150
+ "observerConcurrency",
151
+ ] as const;
152
+ for (const key of numberKeys) {
153
+ const normalizedValue = positiveIntegerOrUndefined(value[key]);
154
+ if (normalizedValue !== undefined) normalized[key] = normalizedValue;
155
+ }
156
+ if (value.chunkOverlapTokens === 0) normalized.chunkOverlapTokens = 0;
157
+ if (typeof value.resumeAfterMidRunCompaction === "boolean") {
158
+ normalized.resumeAfterMidRunCompaction = value.resumeAfterMidRunCompaction;
159
+ }
160
+ if (typeof value.passive === "boolean") normalized.passive = value.passive;
161
+ if (typeof value.debugLog === "boolean") normalized.debugLog = value.debugLog;
162
+ if (isRecord(value.models)) {
163
+ normalized.models = {
164
+ observer: normalizeModel(value.models.observer, base.models.observer),
165
+ consolidator: normalizeModel(value.models.consolidator, base.models.consolidator),
166
+ };
167
+ }
168
+ return normalized;
169
+ }
170
+
171
+ function readJsonFile(path: string): Record<string, unknown> {
172
+ try {
173
+ if (!existsSync(path)) return {};
174
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
175
+ return isRecord(parsed) ? parsed : {};
176
+ } catch {
177
+ return {};
178
+ }
179
+ }
180
+
181
+ function extractDefaultModel(settings: Record<string, unknown>): { provider?: string; id?: string } | undefined {
182
+ const provider = nonEmptyString(settings.defaultProvider);
183
+ const id = nonEmptyString(settings.defaultModel);
184
+ if (!provider && !id) return undefined;
185
+ const model: { provider?: string; id?: string } = {};
186
+ if (provider) model.provider = provider;
187
+ if (id) model.id = id;
188
+ return model;
189
+ }
190
+
191
+ function findSection(settings: Record<string, unknown>): Record<string, unknown> | undefined {
192
+ for (const key of SETTINGS_KEYS) {
193
+ if (isRecord(settings[key])) {
194
+ return settings[key] as Record<string, unknown>;
195
+ }
196
+ }
197
+ return undefined;
198
+ }
199
+
200
+ export function loadConfig(cwd: string = process.cwd()): Config {
201
+ let merged: Config = {
202
+ ...DEFAULTS,
203
+ models: {
204
+ observer: { ...DEFAULTS.models.observer },
205
+ consolidator: { ...DEFAULTS.models.consolidator },
206
+ },
207
+ };
208
+
209
+ const globalSettings = readJsonFile(join(getAgentDir(), "settings.json"));
210
+ const globalSection = findSection(globalSettings);
211
+ if (globalSection) {
212
+ const globalNormalized = normalizeSettingsConfig(globalSection, merged);
213
+ merged = { ...merged, ...globalNormalized };
214
+ }
215
+ const globalDefaultModel = extractDefaultModel(globalSettings);
216
+ if (globalDefaultModel) merged.settingsDefaultModel = globalDefaultModel;
217
+
218
+ const projectSettings = readJsonFile(join(cwd, CONFIG_DIR_NAME, "settings.json"));
219
+ const projectSection = findSection(projectSettings);
220
+ if (projectSection) {
221
+ const projectNormalized = normalizeSettingsConfig(projectSection, merged);
222
+ merged = { ...merged, ...projectNormalized };
223
+ }
224
+ const projectDefaultModel = extractDefaultModel(projectSettings);
225
+ if (projectDefaultModel) merged.settingsDefaultModel = projectDefaultModel;
226
+
227
+ // Environment variable overrides
228
+ if (process.env[PASSIVE_ENV] === "1" || process.env[PASSIVE_ENV]?.toLowerCase() === "true") {
229
+ merged.passive = true;
230
+ }
231
+
232
+ if (process.env.PI_OM_OBSERVER_PROVIDER) {
233
+ merged.models.observer.provider = process.env.PI_OM_OBSERVER_PROVIDER;
234
+ }
235
+ if (process.env.PI_OM_OBSERVER_MODEL) {
236
+ merged.models.observer.id = process.env.PI_OM_OBSERVER_MODEL;
237
+ }
238
+ if (isThinkingLevel(process.env.PI_OM_OBSERVER_THINKING)) {
239
+ merged.models.observer.thinking = process.env.PI_OM_OBSERVER_THINKING as ModelThinkingLevel;
240
+ }
241
+
242
+ if (process.env.PI_OM_CONSOLIDATOR_PROVIDER) {
243
+ merged.models.consolidator.provider = process.env.PI_OM_CONSOLIDATOR_PROVIDER;
244
+ }
245
+ if (process.env.PI_OM_CONSOLIDATOR_MODEL) {
246
+ merged.models.consolidator.id = process.env.PI_OM_CONSOLIDATOR_MODEL;
247
+ }
248
+ if (isThinkingLevel(process.env.PI_OM_CONSOLIDATOR_THINKING)) {
249
+ merged.models.consolidator.thinking = process.env.PI_OM_CONSOLIDATOR_THINKING as ModelThinkingLevel;
250
+ }
251
+
252
+ return merged;
253
+ }
@@ -0,0 +1,76 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, appendFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+
6
+ export const DEBUG_LOG_MAX_BYTES = 10 * 1024 * 1024;
7
+ export const DEBUG_LOG_RELATIVE_PATH = join("memory-log", "debug.ndjson");
8
+ export const DEBUG_LOG_SESSION_DIR_RELATIVE_PATH = join("memory-log", "debug");
9
+
10
+ export interface DebugLogContext {
11
+ enabled: boolean;
12
+ cwd?: string;
13
+ sessionId?: string;
14
+ sessionFile?: string;
15
+ runId?: string;
16
+ }
17
+
18
+ const storage = new AsyncLocalStorage<DebugLogContext>();
19
+
20
+ export function withDebugLogContext<T>(context: DebugLogContext, fn: () => T): T {
21
+ const parent = storage.getStore();
22
+ return storage.run({ ...parent, ...context }, fn);
23
+ }
24
+
25
+ export function safeDebugLogSessionId(sessionId: string | undefined): string | undefined {
26
+ const trimmed = sessionId?.trim();
27
+ if (!trimmed) return undefined;
28
+ const sanitized = trimmed
29
+ .replace(/[^A-Za-z0-9._-]+/g, "_")
30
+ .replace(/^_+|_+$/g, "")
31
+ .slice(0, 128);
32
+ if (!/[A-Za-z0-9]/.test(sanitized)) return undefined;
33
+ return sanitized;
34
+ }
35
+
36
+ export function debugLogRelativePath(context: Pick<DebugLogContext, "sessionId">): string {
37
+ const safeSessionId = safeDebugLogSessionId(context.sessionId);
38
+ return safeSessionId
39
+ ? join(DEBUG_LOG_SESSION_DIR_RELATIVE_PATH, `${safeSessionId}.ndjson`)
40
+ : DEBUG_LOG_RELATIVE_PATH;
41
+ }
42
+
43
+ export function debugLog(event: string, data: Record<string, unknown> = {}): void {
44
+ const context = storage.getStore();
45
+ if (context?.enabled !== true) return;
46
+
47
+ try {
48
+ const path = join(getAgentDir(), debugLogRelativePath(context));
49
+ mkdirSync(dirname(path), { recursive: true });
50
+ rotateIfNeeded(path);
51
+ const payload = {
52
+ ts: new Date().toISOString(),
53
+ event,
54
+ cwd: context.cwd,
55
+ sessionId: context.sessionId,
56
+ sessionFile: context.sessionFile,
57
+ runId: context.runId,
58
+ data,
59
+ };
60
+ appendFileSync(path, `${JSON.stringify(payload)}\n`, "utf-8");
61
+ } catch {
62
+ // Debug logging must never affect memory behavior.
63
+ }
64
+ }
65
+
66
+ function rotateIfNeeded(path: string): void {
67
+ if (!existsSync(path)) return;
68
+ try {
69
+ if (statSync(path).size < DEBUG_LOG_MAX_BYTES) return;
70
+ const backupPath = `${path}.1`;
71
+ if (existsSync(backupPath)) unlinkSync(backupPath);
72
+ renameSync(path, backupPath);
73
+ } catch {
74
+ // ignore
75
+ }
76
+ }
@@ -0,0 +1,161 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { renderMemoryMap } from "../memory/index-render.ts";
3
+ import { listTopics, readJourney } from "../memory/paths.ts";
4
+ import type { Runtime } from "../runtime.ts";
5
+ import {
6
+ buildCompactionProjection,
7
+ entryIndexById,
8
+ findLastCompactionIndex,
9
+ isObservationsRecordedEntry,
10
+ isSourceEntry,
11
+ isValidCutPoint,
12
+ rawTokensAfterIndex,
13
+ renderSummary,
14
+ type Entry,
15
+ } from "../ledger/index.ts";
16
+
17
+ /** Distinct, branch-resolved coversUpToId indices of committed observation chunks, ascending. */
18
+ function chunkBoundaryIndices(branch: Entry[]): number[] {
19
+ const indexes = entryIndexById(branch);
20
+ const set = new Set<number>();
21
+ for (const entry of branch) {
22
+ if (!isObservationsRecordedEntry(entry)) continue;
23
+ const idx = indexes.get(entry.data.coversUpToId);
24
+ if (idx !== undefined) set.add(idx);
25
+ }
26
+ return Array.from(set).sort((a, b) => a - b);
27
+ }
28
+
29
+ /** First source entry after `boundaryIndex` that is a valid cut point, or undefined. */
30
+ function firstKeptAfterBoundary(branch: Entry[], boundaryIndex: number): Entry | undefined {
31
+ for (let i = boundaryIndex + 1; i < branch.length; i++) {
32
+ if (!isSourceEntry(branch[i])) continue;
33
+ return isValidCutPoint(branch[i]) ? branch[i] : undefined;
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ export function snapCutoff(
39
+ branch: Entry[],
40
+ proposedFirstKeptId: string,
41
+ tailTokens: number,
42
+ ): { firstKeptId: string; tail: number | undefined } {
43
+ const indexes = entryIndexById(branch);
44
+ const lastCompactionIdx = findLastCompactionIndex(branch);
45
+ const boundaries = chunkBoundaryIndices(branch);
46
+ let bestId: string | undefined;
47
+ let bestTail: number | undefined;
48
+ let bestDelta = Number.POSITIVE_INFINITY;
49
+
50
+ for (const boundaryIndex of boundaries) {
51
+ if (lastCompactionIdx !== -1 && boundaryIndex <= lastCompactionIdx) {
52
+ continue;
53
+ }
54
+ const firstKept = firstKeptAfterBoundary(branch, boundaryIndex);
55
+ if (!firstKept) continue;
56
+
57
+ const firstKeptIdx = indexes.get(firstKept.id);
58
+ if (lastCompactionIdx !== -1 && (firstKeptIdx === undefined || firstKeptIdx <= lastCompactionIdx)) {
59
+ continue;
60
+ }
61
+
62
+ const tail = rawTokensAfterIndex(branch, boundaryIndex);
63
+ const delta = Math.abs(tail - tailTokens);
64
+ if (delta < bestDelta) {
65
+ bestDelta = delta;
66
+ bestId = firstKept.id;
67
+ bestTail = tail;
68
+ }
69
+ }
70
+
71
+ let finalFirstKeptId = bestId;
72
+ let finalTail = bestTail;
73
+
74
+ // Defensive verification: ensure the returned firstKeptId is NEVER at or before any existing compaction entry on branch.
75
+ if (finalFirstKeptId !== undefined) {
76
+ const finalIdx = indexes.get(finalFirstKeptId);
77
+ if (finalIdx === undefined || (lastCompactionIdx !== -1 && finalIdx <= lastCompactionIdx)) {
78
+ finalFirstKeptId = undefined;
79
+ finalTail = undefined;
80
+ }
81
+ }
82
+
83
+ if (!finalFirstKeptId) {
84
+ finalFirstKeptId = proposedFirstKeptId;
85
+ finalTail = undefined;
86
+ }
87
+
88
+ return { firstKeptId: finalFirstKeptId, tail: finalTail };
89
+ }
90
+
91
+ export function snapFirstKeptEntryId(branch: Entry[], proposedFirstKeptId: string, tailTokens: number): string {
92
+ return snapCutoff(branch, proposedFirstKeptId, tailTokens).firstKeptId;
93
+ }
94
+
95
+ export function canSkipObserverWait(
96
+ branch: Entry[],
97
+ snappedFirstKeptId: string,
98
+ snappedTail: number | undefined,
99
+ tailTokens: number,
100
+ observersInFlight: Iterable<{ coversUpToId: string }>,
101
+ ): boolean {
102
+ if (snappedTail === undefined || snappedTail > tailTokens) return false;
103
+
104
+ const indexes = entryIndexById(branch);
105
+ const cutoffIndex = indexes.get(snappedFirstKeptId);
106
+ if (cutoffIndex === undefined) return false;
107
+
108
+ for (const { coversUpToId } of observersInFlight) {
109
+ const idx = indexes.get(coversUpToId);
110
+ if (idx === undefined || idx < cutoffIndex) return false;
111
+ }
112
+ return true;
113
+ }
114
+
115
+ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void {
116
+ pi.on("session_before_compact", async (event: any, ctx: any) => {
117
+ if (!runtime.enabled || runtime.config.passive) return undefined;
118
+
119
+ const hasUI = ctx.hasUI;
120
+ if (runtime.compactHookInFlight) {
121
+ if (hasUI) ctx.ui.notify("memory-log: another compaction is already in progress, skipping duplicate trigger", "warning");
122
+ return { cancel: true };
123
+ }
124
+
125
+ runtime.compactHookInFlight = true;
126
+ try {
127
+ runtime.ensureConfig(ctx.cwd);
128
+ const tailTokens = runtime.config.tailTokens;
129
+ const { firstKeptEntryId, tokensBefore } = event.preparation;
130
+
131
+ let branch = (ctx.sessionManager?.getBranch?.() as Entry[] | undefined) ?? (event.branchEntries as Entry[]);
132
+ let snap = snapCutoff(branch, firstKeptEntryId, tailTokens);
133
+
134
+ const skip = canSkipObserverWait(branch, snap.firstKeptId, snap.tail, tailTokens, runtime.observersInFlight.values());
135
+ runtime.lastCompactionObserverWait = skip ? "skipped" : "waited";
136
+ if (!skip) {
137
+ if (hasUI) ctx.ui.notify("memory-log: waiting for in-flight observers to finish before compaction...", "info");
138
+ await runtime.whenObserversIdle();
139
+ branch = (ctx.sessionManager?.getBranch?.() as Entry[] | undefined) ?? (event.branchEntries as Entry[]);
140
+ snap = snapCutoff(branch, firstKeptEntryId, tailTokens);
141
+ }
142
+
143
+ const snapped = snap.firstKeptId;
144
+ const projection = buildCompactionProjection(branch, snapped);
145
+ const journey = readJourney(runtime.memoryRoot);
146
+ const map = renderMemoryMap(listTopics(runtime.memoryRoot));
147
+ const summary = renderSummary(journey, map, projection.observations);
148
+
149
+ return {
150
+ compaction: {
151
+ summary,
152
+ firstKeptEntryId: snapped,
153
+ tokensBefore,
154
+ details: projection.details,
155
+ },
156
+ };
157
+ } finally {
158
+ runtime.compactHookInFlight = false;
159
+ }
160
+ });
161
+ }