@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.
- package/README.md +53 -2
- package/extensions/memory-log/README.md +54 -0
- package/extensions/memory-log/agent/consolidator/prompt.ts +47 -0
- package/extensions/memory-log/agent/consolidator/tools.ts +160 -0
- package/extensions/memory-log/agent/cost.ts +32 -0
- package/extensions/memory-log/agent/index.ts +42 -0
- package/extensions/memory-log/agent/observer/prompt.ts +73 -0
- package/extensions/memory-log/agent/observer/tool.ts +73 -0
- package/extensions/memory-log/commands/compact.ts +35 -0
- package/extensions/memory-log/commands/consolidate.ts +47 -0
- package/extensions/memory-log/commands/status.ts +50 -0
- package/extensions/memory-log/config.ts +253 -0
- package/extensions/memory-log/debug-log.ts +76 -0
- package/extensions/memory-log/hooks/compaction-hook.ts +161 -0
- package/extensions/memory-log/hooks/compaction-trigger.ts +160 -0
- package/extensions/memory-log/hooks/consolidator-trigger.ts +143 -0
- package/extensions/memory-log/hooks/observer-trigger.ts +176 -0
- package/extensions/memory-log/ids.ts +78 -0
- package/extensions/memory-log/index.ts +149 -0
- package/extensions/memory-log/ledger/fold.ts +77 -0
- package/extensions/memory-log/ledger/index.ts +7 -0
- package/extensions/memory-log/ledger/pool.ts +38 -0
- package/extensions/memory-log/ledger/progress.ts +181 -0
- package/extensions/memory-log/ledger/projection.ts +115 -0
- package/extensions/memory-log/ledger/render.ts +38 -0
- package/extensions/memory-log/ledger/serialize.ts +161 -0
- package/extensions/memory-log/ledger/types.ts +201 -0
- package/extensions/memory-log/memory/index-render.ts +45 -0
- package/extensions/memory-log/memory/paths.ts +120 -0
- package/extensions/memory-log/memory/session.ts +64 -0
- package/extensions/memory-log/runtime.ts +127 -0
- package/extensions/memory-log/spawn/launch.ts +133 -0
- package/extensions/memory-log/spawn/runs.ts +83 -0
- package/extensions/memory-log/tokens.ts +66 -0
- package/extensions/memory-log/ui/status-controller.ts +225 -0
- package/extensions/memory-log/ui/timeline.ts +103 -0
- package/package.json +41 -4
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory-log — Observational Memory Log (Orchestrator).
|
|
3
|
+
*
|
|
4
|
+
* Tiered asynchronous subprocess observational memory system:
|
|
5
|
+
* - Observers: Background concurrent distillation of conversation chunks into timestamped atomic facts;
|
|
6
|
+
* - Ledger: Persists with branches to the current session;
|
|
7
|
+
* - Compaction: Model-free deterministic context reconstruction (Journey, Memory Map, short-term buffer);
|
|
8
|
+
* - Consolidator: Auto-archives older observations to durable topic files under .memory/<sessionId>/ and JOURNEY.md.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
13
|
+
import { handleCompactCommand, registerCompactCommand } from "./commands/compact.ts";
|
|
14
|
+
import { handleConsolidateCommand, registerConsolidateCommand } from "./commands/consolidate.ts";
|
|
15
|
+
import { handleStatusCommand, registerStatusCommand } from "./commands/status.ts";
|
|
16
|
+
import { registerCompactionHook } from "./hooks/compaction-hook.ts";
|
|
17
|
+
import { registerCompactionTrigger } from "./hooks/compaction-trigger.ts";
|
|
18
|
+
import { registerConsolidatorTrigger } from "./hooks/consolidator-trigger.ts";
|
|
19
|
+
import { registerObserverTrigger } from "./hooks/observer-trigger.ts";
|
|
20
|
+
import { OM_ENABLED, type Entry } from "./ledger/index.ts";
|
|
21
|
+
import { ensureSessionMemory } from "./memory/session.ts";
|
|
22
|
+
import { Runtime } from "./runtime.ts";
|
|
23
|
+
|
|
24
|
+
function readGateFromLedger(branch: Entry[], defaultEnabled: boolean): boolean {
|
|
25
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
26
|
+
const entry = branch[i];
|
|
27
|
+
if (entry.type === "custom" && entry.customType === OM_ENABLED) {
|
|
28
|
+
return (entry.data as { enabled?: boolean } | undefined)?.enabled ?? defaultEnabled;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return defaultEnabled;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default function observationalMemory(pi: ExtensionAPI): void {
|
|
35
|
+
const runtime = new Runtime();
|
|
36
|
+
|
|
37
|
+
function attachIfEnabled(ctx: any): void {
|
|
38
|
+
if (runtime.enabled && ctx.mode === "tui" && ctx.hasUI && ctx.ui) {
|
|
39
|
+
runtime.status.attach(ctx.ui);
|
|
40
|
+
} else {
|
|
41
|
+
runtime.status.detach();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
pi.on("session_start", (_event: unknown, ctx: any) => {
|
|
46
|
+
runtime.ensureConfig(ctx.cwd);
|
|
47
|
+
runtime.dispatchedCoversUpToId = undefined;
|
|
48
|
+
const branch = ctx.sessionManager.getBranch() as Entry[];
|
|
49
|
+
runtime.enabled = readGateFromLedger(branch, runtime.config.defaultEnabled);
|
|
50
|
+
if (runtime.enabled) runtime.memoryRoot = ensureSessionMemory(ctx);
|
|
51
|
+
attachIfEnabled(ctx);
|
|
52
|
+
runtime.refreshFooterGauges(branch, ctx.getContextUsage?.()?.tokens ?? null);
|
|
53
|
+
runtime.refreshCost(ctx.sessionManager.getEntries() as Entry[]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
pi.on("session_shutdown", () => {
|
|
57
|
+
runtime.status.detach();
|
|
58
|
+
runtime.abortAllWorkers();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const setGate = (next: boolean, ctx: ExtensionContext) => {
|
|
62
|
+
if (next === runtime.enabled) {
|
|
63
|
+
if (ctx.hasUI) ctx.ui.notify(`memory-log is already ${next ? "enabled" : "disabled"}`, "info");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
runtime.enabled = next;
|
|
67
|
+
pi.appendEntry(OM_ENABLED, { enabled: next });
|
|
68
|
+
if (next) {
|
|
69
|
+
runtime.memoryRoot = ensureSessionMemory(ctx as any);
|
|
70
|
+
attachIfEnabled(ctx);
|
|
71
|
+
runtime.refreshFooterGauges(
|
|
72
|
+
(ctx as any).sessionManager.getBranch() as Entry[],
|
|
73
|
+
(ctx as any).getContextUsage?.()?.tokens ?? null,
|
|
74
|
+
);
|
|
75
|
+
runtime.refreshCost((ctx as any).sessionManager.getEntries() as Entry[]);
|
|
76
|
+
} else {
|
|
77
|
+
runtime.abortAllWorkers();
|
|
78
|
+
runtime.status.detach();
|
|
79
|
+
}
|
|
80
|
+
if (ctx.hasUI) ctx.ui.notify(`memory-log is now ${next ? "enabled" : "disabled"}`, "info");
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const getCompletions = (prefix: string): AutocompleteItem[] | null => {
|
|
84
|
+
const candidates: AutocompleteItem[] = [
|
|
85
|
+
{ value: "status", label: "status", description: "View memory log system status and timeline" },
|
|
86
|
+
{ value: "on", label: "on", description: "Enable memory log for current session" },
|
|
87
|
+
{ value: "off", label: "off", description: "Disable memory log for current session" },
|
|
88
|
+
{ value: "compact", label: "compact", description: "Run memory compaction immediately (ignore context threshold)" },
|
|
89
|
+
{ value: "consolidate", label: "consolidate", description: "Archive short-term observations to durable topic files immediately" },
|
|
90
|
+
];
|
|
91
|
+
const trimmed = prefix.trimStart();
|
|
92
|
+
const filtered = candidates.filter((item) => item.value.startsWith(trimmed));
|
|
93
|
+
return filtered.length > 0 ? filtered : null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const commandHandler = async (args: string, ctx: ExtensionContext) => {
|
|
97
|
+
const action = (args.trim().split(/\s+/)[0] || "").toLowerCase();
|
|
98
|
+
|
|
99
|
+
if (action === "on") {
|
|
100
|
+
setGate(true, ctx);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (action === "off") {
|
|
105
|
+
setGate(false, ctx);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (action === "compact") {
|
|
110
|
+
await handleCompactCommand(args, ctx, runtime);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (action === "consolidate") {
|
|
115
|
+
await handleConsolidateCommand(args, ctx, runtime);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (action === "status" || action === "timeline" || !action) {
|
|
120
|
+
if (!action && !runtime.enabled) {
|
|
121
|
+
setGate(true, ctx);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
await handleStatusCommand(args, ctx, runtime);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Toggle gate if unknown action
|
|
129
|
+
setGate(!runtime.enabled, ctx);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Primary command: /mem-log [status|on|off|compact|consolidate]
|
|
133
|
+
pi.registerCommand("mem-log", {
|
|
134
|
+
description: "Manage observational memory log system (/mem-log [status|on|off|compact|consolidate])",
|
|
135
|
+
getArgumentCompletions: getCompletions,
|
|
136
|
+
handler: commandHandler,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Triggers + hooks
|
|
140
|
+
registerObserverTrigger(pi, runtime);
|
|
141
|
+
registerConsolidatorTrigger(pi, runtime);
|
|
142
|
+
registerCompactionTrigger(pi, runtime);
|
|
143
|
+
registerCompactionHook(pi, runtime);
|
|
144
|
+
|
|
145
|
+
// Subcommands (:status, :compact, :consolidate)
|
|
146
|
+
registerStatusCommand(pi, runtime);
|
|
147
|
+
registerCompactCommand(pi, runtime);
|
|
148
|
+
registerConsolidateCommand(pi, runtime);
|
|
149
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isObservationsDroppedData,
|
|
3
|
+
isObservationsRecordedData,
|
|
4
|
+
OM_OBSERVATIONS_DROPPED,
|
|
5
|
+
OM_OBSERVATIONS_RECORDED,
|
|
6
|
+
type Entry,
|
|
7
|
+
type Observation,
|
|
8
|
+
} from "./types.ts";
|
|
9
|
+
|
|
10
|
+
export type FoldLedgerOptions = {
|
|
11
|
+
/** Fold entries from branch root through this entry id, inclusive. Omit to fold through branch tip. */
|
|
12
|
+
upToEntryId?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type FoldedLedger = {
|
|
16
|
+
/** Every first-valid observation record through the fold boundary, including dropped ones. */
|
|
17
|
+
observations: Observation[];
|
|
18
|
+
/** Observations not tombstoned by a folded drop entry — the live injected buffer. */
|
|
19
|
+
activeObservations: Observation[];
|
|
20
|
+
/** Tombstoned observation timestamps, including ids with no corresponding folded observation. */
|
|
21
|
+
droppedObservationTimestamps: Set<string>;
|
|
22
|
+
/** First-valid observation records by timestamp (id), including dropped ones. */
|
|
23
|
+
observationsByTimestamp: Map<string, Observation>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function foldEndIndex(entries: Entry[], upToEntryId: string | undefined): number {
|
|
27
|
+
if (!upToEntryId) return entries.length - 1;
|
|
28
|
+
const idx = entries.findIndex((entry) => entry.id === upToEntryId);
|
|
29
|
+
return idx === -1 ? entries.length - 1 : idx;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isCustomEntry(entry: Entry, customType: string): boolean {
|
|
33
|
+
return entry.type === "custom" && entry.customType === customType;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Fold valid memory ledger entries from the branch root through the target entry.
|
|
38
|
+
*/
|
|
39
|
+
export function foldLedger(entries: Entry[], options: FoldLedgerOptions = {}): FoldedLedger {
|
|
40
|
+
const observationsByTimestamp = new Map<string, Observation>();
|
|
41
|
+
const droppedObservationTimestamps = new Set<string>();
|
|
42
|
+
const endIdx = foldEndIndex(entries, options.upToEntryId);
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i <= endIdx; i++) {
|
|
45
|
+
const entry = entries[i];
|
|
46
|
+
if (!entry) continue;
|
|
47
|
+
|
|
48
|
+
if (isCustomEntry(entry, OM_OBSERVATIONS_RECORDED)) {
|
|
49
|
+
if (!isObservationsRecordedData(entry.data)) continue;
|
|
50
|
+
for (const observation of entry.data.observations) {
|
|
51
|
+
if (!observationsByTimestamp.has(observation.timestamp)) {
|
|
52
|
+
observationsByTimestamp.set(observation.timestamp, observation);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (isCustomEntry(entry, OM_OBSERVATIONS_DROPPED)) {
|
|
59
|
+
if (!isObservationsDroppedData(entry.data)) continue;
|
|
60
|
+
for (const timestamp of entry.data.observationTimestamps) {
|
|
61
|
+
droppedObservationTimestamps.add(timestamp);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const observations = Array.from(observationsByTimestamp.values());
|
|
67
|
+
const activeObservations = observations.filter(
|
|
68
|
+
(observation) => !droppedObservationTimestamps.has(observation.timestamp),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
observations,
|
|
73
|
+
activeObservations,
|
|
74
|
+
droppedObservationTimestamps,
|
|
75
|
+
observationsByTimestamp,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { sortObservations } from "./render.ts";
|
|
2
|
+
import type { Observation } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export function poolTokens(observations: Observation[]): number {
|
|
5
|
+
let total = 0;
|
|
6
|
+
for (const observation of observations) total += observation.tokenCount;
|
|
7
|
+
return total;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type PromotionOverflow = {
|
|
11
|
+
/** Oldest observations to hand to the consolidator (chronological order). */
|
|
12
|
+
promote: Observation[];
|
|
13
|
+
/** Token total of the observations kept in the buffer. */
|
|
14
|
+
keptTokens: number;
|
|
15
|
+
/** Token total of the whole active pool. */
|
|
16
|
+
totalTokens: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Select the promotion overflow: keep the newest observations whose cumulative tokens stay
|
|
21
|
+
* within `poolTargetTokens`, promote everything older. Always keeps at least the single
|
|
22
|
+
* newest observation, even if it alone exceeds the target.
|
|
23
|
+
*/
|
|
24
|
+
export function selectPromotionOverflow(active: Observation[], poolTargetTokens: number): PromotionOverflow {
|
|
25
|
+
const sorted = sortObservations(active);
|
|
26
|
+
const totalTokens = poolTokens(sorted);
|
|
27
|
+
|
|
28
|
+
let keptTokens = 0;
|
|
29
|
+
let firstKeptIdx = sorted.length;
|
|
30
|
+
for (let i = sorted.length - 1; i >= 0; i--) {
|
|
31
|
+
const t = sorted[i].tokenCount;
|
|
32
|
+
if (firstKeptIdx !== sorted.length && keptTokens + t > poolTargetTokens) break;
|
|
33
|
+
keptTokens += t;
|
|
34
|
+
firstKeptIdx = i;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { promote: sorted.slice(0, firstKeptIdx), keptTokens, totalTokens };
|
|
38
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { estimateEntryTokens } from "../tokens.ts";
|
|
2
|
+
import {
|
|
3
|
+
OM_OBSERVATIONS_DROPPED,
|
|
4
|
+
OM_OBSERVATIONS_RECORDED,
|
|
5
|
+
type Entry,
|
|
6
|
+
type MemoryCustomType,
|
|
7
|
+
} from "./types.ts";
|
|
8
|
+
|
|
9
|
+
const SOURCE_ENTRY_TYPES = new Set(["message", "custom_message", "branch_summary"]);
|
|
10
|
+
|
|
11
|
+
export function isSourceEntry(entry: Entry): boolean {
|
|
12
|
+
return SOURCE_ENTRY_TYPES.has(entry.type);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A source entry is a valid chunk/compaction boundary only if it can legitimately START a
|
|
17
|
+
* chunk — i.e. it is not a tool-result message. Anchoring boundaries to non-tool-result entries
|
|
18
|
+
* keeps every tool call together with its result in the same chunk (and the same verbatim tail
|
|
19
|
+
* at compaction).
|
|
20
|
+
*/
|
|
21
|
+
export function isValidCutPoint(entry: Entry): boolean {
|
|
22
|
+
if (entry.type === "custom_message" || entry.type === "branch_summary") return true;
|
|
23
|
+
if (entry.type === "message") {
|
|
24
|
+
const role = (entry.message as { role?: string } | undefined)?.role;
|
|
25
|
+
return role === "user" || role === "assistant";
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Id of the last source entry on the branch (the tip). Tombstone `coversUpToId` anchor. */
|
|
31
|
+
export function lastSourceEntryId(entries: Entry[]): string | undefined {
|
|
32
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
33
|
+
if (isSourceEntry(entries[i])) return entries[i].id;
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function entryIndexById(entries: Entry[]): Map<string, number> {
|
|
39
|
+
const idToIndex = new Map<string, number>();
|
|
40
|
+
for (let i = 0; i < entries.length; i++) idToIndex.set(entries[i].id, i);
|
|
41
|
+
return idToIndex;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function entryIndexForId(entries: Entry[], entryId: string | undefined): number {
|
|
45
|
+
if (!entryId) return -1;
|
|
46
|
+
const idx = entryIndexById(entries).get(entryId);
|
|
47
|
+
return idx ?? -1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
51
|
+
return typeof value === "object" && value !== null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isNonEmptyArray(value: unknown): value is unknown[] {
|
|
55
|
+
return Array.isArray(value) && value.length > 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isValidCoverageEntry(entry: Entry, customType: MemoryCustomType): entry is Entry & { data: { coversUpToId: string } } {
|
|
59
|
+
if (entry.type !== "custom" || entry.customType !== customType) return false;
|
|
60
|
+
if (!isObject(entry.data) || typeof entry.data.coversUpToId !== "string") return false;
|
|
61
|
+
|
|
62
|
+
if (customType === OM_OBSERVATIONS_RECORDED) return isNonEmptyArray(entry.data.observations);
|
|
63
|
+
return isNonEmptyArray(entry.data.observationTimestamps);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function latestCoverageIndex(entries: Entry[], customType: MemoryCustomType): number {
|
|
67
|
+
const idToIndex = entryIndexById(entries);
|
|
68
|
+
let latest = -1;
|
|
69
|
+
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (!isValidCoverageEntry(entry, customType)) continue;
|
|
72
|
+
const coveredIndex = idToIndex.get(entry.data.coversUpToId);
|
|
73
|
+
if (coveredIndex === undefined) continue;
|
|
74
|
+
if (coveredIndex > latest) latest = coveredIndex;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return latest;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function latestCoverageMarkerId(entries: Entry[], customType: MemoryCustomType): string | undefined {
|
|
81
|
+
const idToIndex = entryIndexById(entries);
|
|
82
|
+
let latestIndex = -1;
|
|
83
|
+
let latestMarkerId: string | undefined;
|
|
84
|
+
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (!isValidCoverageEntry(entry, customType)) continue;
|
|
87
|
+
const coveredIndex = idToIndex.get(entry.data.coversUpToId);
|
|
88
|
+
if (coveredIndex === undefined) continue;
|
|
89
|
+
if (coveredIndex > latestIndex) {
|
|
90
|
+
latestIndex = coveredIndex;
|
|
91
|
+
latestMarkerId = entry.data.coversUpToId;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return latestMarkerId;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function earlierCoverageMarkerId(
|
|
99
|
+
entries: Entry[],
|
|
100
|
+
firstId: string | undefined,
|
|
101
|
+
secondId: string | undefined,
|
|
102
|
+
): string | undefined {
|
|
103
|
+
if (!firstId) return secondId;
|
|
104
|
+
if (!secondId) return firstId;
|
|
105
|
+
|
|
106
|
+
const idToIndex = entryIndexById(entries);
|
|
107
|
+
const firstIndex = idToIndex.get(firstId);
|
|
108
|
+
const secondIndex = idToIndex.get(secondId);
|
|
109
|
+
if (firstIndex === undefined) return secondIndex === undefined ? undefined : secondId;
|
|
110
|
+
if (secondIndex === undefined) return firstId;
|
|
111
|
+
return firstIndex <= secondIndex ? firstId : secondId;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function rawTokensAfterIndex(entries: Entry[], index: number): number {
|
|
115
|
+
let total = 0;
|
|
116
|
+
for (let i = Math.max(0, index + 1); i < entries.length; i++) {
|
|
117
|
+
if (isSourceEntry(entries[i])) total += estimateEntryTokens(entries[i]);
|
|
118
|
+
}
|
|
119
|
+
return total;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function rawTokensSinceCoverage(entries: Entry[], customType: MemoryCustomType): number {
|
|
123
|
+
return rawTokensAfterIndex(entries, latestCoverageIndex(entries, customType));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function rawTokensSinceObservationCoverage(entries: Entry[]): number {
|
|
127
|
+
return rawTokensSinceCoverage(entries, OM_OBSERVATIONS_RECORDED);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function rawTokensSinceDropCoverage(entries: Entry[]): number {
|
|
131
|
+
return rawTokensSinceCoverage(entries, OM_OBSERVATIONS_DROPPED);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function findLastCompactionIndex(entries: Entry[]): number {
|
|
135
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
136
|
+
if (entries[i].type === "compaction") return i;
|
|
137
|
+
}
|
|
138
|
+
return -1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function rawTokensSinceLastCompaction(entries: Entry[]): number {
|
|
142
|
+
const compactionIndex = findLastCompactionIndex(entries);
|
|
143
|
+
if (compactionIndex === -1) return rawTokensAfterIndex(entries, -1);
|
|
144
|
+
|
|
145
|
+
const firstKeptEntryId = entries[compactionIndex].firstKeptEntryId;
|
|
146
|
+
const firstKeptIndex = entryIndexForId(entries, firstKeptEntryId);
|
|
147
|
+
|
|
148
|
+
if (firstKeptIndex === -1) return rawTokensAfterIndex(entries, compactionIndex);
|
|
149
|
+
return rawTokensAfterIndex(entries, firstKeptIndex - 1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type SourceSlice = {
|
|
153
|
+
entries: Entry[];
|
|
154
|
+
/** Id of the last source entry in the slice → the chunk's `coversUpToId` watermark. */
|
|
155
|
+
coversUpToId: string | undefined;
|
|
156
|
+
tokens: number;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Select the next observation chunk: the source entries strictly after `afterEntryId`
|
|
161
|
+
* (the latest covered watermark), accumulated until adding the next entry would exceed
|
|
162
|
+
* `chunkTokens`.
|
|
163
|
+
*/
|
|
164
|
+
export function selectSourceSlice(entries: Entry[], afterEntryId: string | undefined, chunkTokens: number): SourceSlice {
|
|
165
|
+
const startIndex = afterEntryId ? entryIndexForId(entries, afterEntryId) : -1;
|
|
166
|
+
const slice: Entry[] = [];
|
|
167
|
+
let tokens = 0;
|
|
168
|
+
let coversUpToId: string | undefined;
|
|
169
|
+
|
|
170
|
+
for (let i = Math.max(0, startIndex + 1); i < entries.length; i++) {
|
|
171
|
+
const entry = entries[i];
|
|
172
|
+
if (!isSourceEntry(entry)) continue;
|
|
173
|
+
const entryTokens = estimateEntryTokens(entry);
|
|
174
|
+
if (slice.length > 0 && tokens + entryTokens > chunkTokens && isValidCutPoint(entry)) break;
|
|
175
|
+
slice.push(entry);
|
|
176
|
+
tokens += entryTokens;
|
|
177
|
+
coversUpToId = entry.id;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { entries: slice, coversUpToId, tokens };
|
|
181
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OM_FOLDED,
|
|
3
|
+
isMemoryDetails,
|
|
4
|
+
isObservationsDroppedEntry,
|
|
5
|
+
isObservationsRecordedEntry,
|
|
6
|
+
type Entry,
|
|
7
|
+
type MemoryDetails,
|
|
8
|
+
type Observation,
|
|
9
|
+
} from "./types.ts";
|
|
10
|
+
|
|
11
|
+
export type Projection = {
|
|
12
|
+
observations: Observation[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type CompactionProjection = Projection & {
|
|
16
|
+
details: MemoryDetails;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ProjectionBoundary =
|
|
20
|
+
| { kind: "entry"; entryId: string }
|
|
21
|
+
| { kind: "beforeEntry"; entryId: string }
|
|
22
|
+
| { kind: "tip" }
|
|
23
|
+
| { kind: "none" };
|
|
24
|
+
|
|
25
|
+
function entryIndexById(entries: Entry[]): Map<string, number> {
|
|
26
|
+
const indexes = new Map<string, number>();
|
|
27
|
+
for (let i = 0; i < entries.length; i++) indexes.set(entries[i].id, i);
|
|
28
|
+
return indexes;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function boundaryIndex(entries: Entry[], indexes: Map<string, number>, boundary: ProjectionBoundary): number {
|
|
32
|
+
if (boundary.kind === "tip") return entries.length - 1;
|
|
33
|
+
if (boundary.kind === "none") return -1;
|
|
34
|
+
const idx = indexes.get(boundary.entryId);
|
|
35
|
+
if (idx === undefined) return -1;
|
|
36
|
+
return boundary.kind === "beforeEntry" ? idx - 1 : idx;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function coverageIndex(entry: Entry & { data: { coversUpToId: string } }, indexes: Map<string, number>): number {
|
|
40
|
+
return indexes.get(entry.data.coversUpToId) ?? -1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isCoveredAtOrBefore(
|
|
44
|
+
entry: Entry & { data: { coversUpToId: string } },
|
|
45
|
+
indexes: Map<string, number>,
|
|
46
|
+
boundary: number,
|
|
47
|
+
): boolean {
|
|
48
|
+
const index = coverageIndex(entry, indexes);
|
|
49
|
+
return index >= 0 && boundary >= 0 && index <= boundary;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fold active observations whose source coverage lies at-or-before `boundary`, in
|
|
54
|
+
* first-valid-record-wins order, with folded drops applied.
|
|
55
|
+
*/
|
|
56
|
+
function foldProjection(entries: Entry[], boundary: ProjectionBoundary): Projection {
|
|
57
|
+
const indexes = entryIndexById(entries);
|
|
58
|
+
const boundaryIdx = boundaryIndex(entries, indexes, boundary);
|
|
59
|
+
const observations: Observation[] = [];
|
|
60
|
+
const seen = new Set<string>();
|
|
61
|
+
const dropped = new Set<string>();
|
|
62
|
+
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (isObservationsRecordedEntry(entry) && isCoveredAtOrBefore(entry, indexes, boundaryIdx)) {
|
|
65
|
+
for (const observation of entry.data.observations) {
|
|
66
|
+
if (seen.has(observation.timestamp)) continue;
|
|
67
|
+
seen.add(observation.timestamp);
|
|
68
|
+
observations.push(observation);
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (isObservationsDroppedEntry(entry) && isCoveredAtOrBefore(entry, indexes, boundaryIdx)) {
|
|
73
|
+
for (const timestamp of entry.data.observationTimestamps) dropped.add(timestamp);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { observations: observations.filter((observation) => !dropped.has(observation.timestamp)) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function fullProjection(entries: Entry[], upToEntryId?: string): Projection {
|
|
81
|
+
return foldProjection(entries, upToEntryId ? { kind: "entry", entryId: upToEntryId } : { kind: "tip" });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function latestCompactionDetails(entries: Entry[]): MemoryDetails | undefined {
|
|
85
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
86
|
+
const entry = entries[i];
|
|
87
|
+
if (entry.type !== "compaction") continue;
|
|
88
|
+
if (isMemoryDetails(entry.details)) return entry.details;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What a fresh session sees: the observations folded into the last compaction. */
|
|
94
|
+
export function visibleProjection(entries: Entry[], upToEntryId?: string): Projection {
|
|
95
|
+
if (!upToEntryId) {
|
|
96
|
+
const details = latestCompactionDetails(entries);
|
|
97
|
+
return details ? { observations: [...details.observations] } : { observations: [] };
|
|
98
|
+
}
|
|
99
|
+
return fullProjection(entries, upToEntryId);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build the deterministic compaction projection: active observations covered up to
|
|
104
|
+
* `firstKeptEntryId`, plus the `details` blob stamped into the compaction entry so a
|
|
105
|
+
* future visible projection can replay it.
|
|
106
|
+
*/
|
|
107
|
+
export function buildCompactionProjection(entries: Entry[], firstKeptEntryId: string): CompactionProjection {
|
|
108
|
+
const projection = foldProjection(entries, { kind: "beforeEntry", entryId: firstKeptEntryId });
|
|
109
|
+
const details: MemoryDetails = {
|
|
110
|
+
type: OM_FOLDED,
|
|
111
|
+
version: 1,
|
|
112
|
+
observations: projection.observations,
|
|
113
|
+
};
|
|
114
|
+
return { observations: projection.observations, details };
|
|
115
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Observation } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const CONTEXT_USAGE_INSTRUCTIONS = `These are condensed memories from earlier in this session.
|
|
4
|
+
|
|
5
|
+
- Journey: a short, purely descriptive history of how this work reached its current state — for orientation only. It is not an instruction or a plan; do not read intent or next steps into it.
|
|
6
|
+
- Observations: timestamped events from the conversation history, in chronological order.
|
|
7
|
+
|
|
8
|
+
Treat these as past records. When entries conflict, the most recent observation reflects the latest known state. Work that prior observations describe as completed should not be redone unless the user explicitly asks to revisit it.`;
|
|
9
|
+
|
|
10
|
+
/** A single observation line: "YYYY-MM-DDTHH:MM:SS content". The timestamp is the id. */
|
|
11
|
+
export function observationToLine(observation: Observation): string {
|
|
12
|
+
return `${observation.timestamp} ${observation.content}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Sort observations chronologically by their timestamp-id (lexicographic == chronological). */
|
|
16
|
+
export function sortObservations(observations: Observation[]): Observation[] {
|
|
17
|
+
return [...observations].sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Render the deterministic injection block:
|
|
22
|
+
* 1. Journey — the running descriptive project history (`.memory/JOURNEY.md`, read verbatim).
|
|
23
|
+
* 2. Memory map — durable topic files rendered from front-matter (`.memory/`).
|
|
24
|
+
* 3. Observations — the bounded short-term buffer, chronological and verbatim.
|
|
25
|
+
*/
|
|
26
|
+
export function renderSummary(journey: string | undefined, map: string | undefined, observations: Observation[]): string {
|
|
27
|
+
const sorted = sortObservations(observations);
|
|
28
|
+
const journeyText = journey?.trim();
|
|
29
|
+
if (!journeyText && !map && sorted.length === 0) return "";
|
|
30
|
+
|
|
31
|
+
const parts: string[] = [CONTEXT_USAGE_INSTRUCTIONS];
|
|
32
|
+
if (journeyText) parts.push(`## Journey\n${journeyText}`);
|
|
33
|
+
if (map && map.trim().length > 0) parts.push(map);
|
|
34
|
+
if (sorted.length > 0) {
|
|
35
|
+
parts.push(`## Observations\n${sorted.map(observationToLine).join("\n")}`);
|
|
36
|
+
}
|
|
37
|
+
return parts.join("\n\n");
|
|
38
|
+
}
|