@rol1n/memory-log 0.0.0-stage → 0.1.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.
@@ -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
+ }
@@ -0,0 +1,161 @@
1
+ import type { Message, TextContent, ToolResultMessage } from "@earendil-works/pi-ai";
2
+
3
+ function pad(n: number): string {
4
+ return n.toString().padStart(2, "0");
5
+ }
6
+
7
+ function fmtLocal(d: Date): string {
8
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
9
+ }
10
+
11
+ function formatTimestamp(v: number | string | undefined): string {
12
+ if (v === undefined) return "????-??-?? ??:??";
13
+ const d = new Date(v);
14
+ return Number.isNaN(d.getTime()) ? "????-??-?? ??:??" : fmtLocal(d);
15
+ }
16
+
17
+ export function nowTimestamp(): string {
18
+ return fmtLocal(new Date());
19
+ }
20
+
21
+ export const MAX_RECORD_CONTENT_CHARS = 10_000;
22
+
23
+ export function truncateRecordContent(content: string): string {
24
+ if (content.length <= MAX_RECORD_CONTENT_CHARS) return content;
25
+ const head = content.slice(0, MAX_RECORD_CONTENT_CHARS);
26
+ const dropped = content.length - MAX_RECORD_CONTENT_CHARS;
27
+ return `${head} … [truncated ${dropped} chars]`;
28
+ }
29
+
30
+ function textAndPlaceholders(content: unknown, options: { includeThinking?: boolean } = {}): string {
31
+ if (typeof content === "string") return content;
32
+ if (!Array.isArray(content)) return "[non-text content omitted]";
33
+
34
+ const parts: string[] = [];
35
+ for (const block of content as Array<Record<string, unknown>>) {
36
+ if (!block || typeof block !== "object") {
37
+ parts.push("[non-text content omitted]");
38
+ continue;
39
+ }
40
+ if (block.type === "text" && typeof block.text === "string") {
41
+ parts.push(block.text);
42
+ continue;
43
+ }
44
+ if (block.type === "thinking") {
45
+ if (options.includeThinking && typeof block.thinking === "string") {
46
+ parts.push(`[thinking: ${block.thinking}]`);
47
+ continue;
48
+ }
49
+ parts.push("[non-text content omitted]");
50
+ continue;
51
+ }
52
+ if (block.type === "toolCall" && typeof block.name === "string") {
53
+ parts.push(`[${block.name}(${JSON.stringify(block.arguments ?? {})})]`);
54
+ continue;
55
+ }
56
+ parts.push("[non-text content omitted]");
57
+ }
58
+ return parts.join("\n");
59
+ }
60
+
61
+ function textOnly(content: unknown): string {
62
+ if (content == null) return "";
63
+ if (typeof content === "string") return content;
64
+ if (!Array.isArray(content)) return "";
65
+ return content
66
+ .filter((b): b is TextContent => b?.type === "text" && typeof b.text === "string")
67
+ .map((b) => b.text)
68
+ .join("\n");
69
+ }
70
+
71
+ export function serializeConversation(messages: Message[]): string {
72
+ return messages
73
+ .map((msg): string | null => {
74
+ const time = formatTimestamp(msg.timestamp);
75
+ if (msg.role === "user") {
76
+ return `[User @ ${time}]: ${textOnly(msg.content)}`;
77
+ }
78
+ if (msg.role === "assistant") {
79
+ const body = textAndPlaceholders(msg.content, { includeThinking: true })
80
+ .split("\n")
81
+ .filter(Boolean)
82
+ .join("\n");
83
+ if (!body) return null;
84
+ return `[Assistant @ ${time}]: ${body}`;
85
+ }
86
+ return `[Tool result for ${(msg as ToolResultMessage).toolName} @ ${time}]: ${textOnly(msg.content)}`;
87
+ })
88
+ .filter((line): line is string => line !== null)
89
+ .join("\n\n");
90
+ }
91
+
92
+ export type RenderableEntry = {
93
+ type: string;
94
+ id?: string;
95
+ timestamp?: string;
96
+ message?: unknown;
97
+ customType?: string;
98
+ content?: unknown;
99
+ summary?: unknown;
100
+ };
101
+
102
+ function renderCustomMessage(entry: RenderableEntry): string {
103
+ const time = formatTimestamp(entry.timestamp);
104
+ const text =
105
+ typeof entry.content === "string"
106
+ ? entry.content
107
+ : Array.isArray(entry.content)
108
+ ? (entry.content as Array<{ type?: string; text?: string }>)
109
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
110
+ .map((b) => b.text as string)
111
+ .join("\n")
112
+ : "";
113
+ const tag = entry.customType ? `Custom (${entry.customType})` : "Custom";
114
+ return `[${tag} @ ${time}]: ${text}`;
115
+ }
116
+
117
+ export function serializeBranchEntries(entries: RenderableEntry[]): string {
118
+ const blocks: string[] = [];
119
+ for (const entry of entries) {
120
+ if (entry.type === "message" && entry.message) {
121
+ const part = serializeConversation([entry.message as Message]);
122
+ if (part) blocks.push(part);
123
+ continue;
124
+ }
125
+ if (entry.type === "custom_message") {
126
+ blocks.push(renderCustomMessage(entry));
127
+ continue;
128
+ }
129
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
130
+ const time = formatTimestamp(entry.timestamp);
131
+ blocks.push(`[Branch summary @ ${time}]: ${entry.summary}`);
132
+ }
133
+ }
134
+ return blocks.join("\n\n");
135
+ }
136
+
137
+ export type SourceAddressedSerialization = {
138
+ text: string;
139
+ sourceEntryIds: string[];
140
+ };
141
+
142
+ function isSourceRenderableEntry(entry: RenderableEntry): boolean {
143
+ return entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary";
144
+ }
145
+
146
+ /**
147
+ * Serialize a slice of source entries into an observer prompt chunk, each block prefixed
148
+ * with its source entry id.
149
+ */
150
+ export function serializeSourceAddressedBranchEntries(entries: RenderableEntry[]): SourceAddressedSerialization {
151
+ const blocks: string[] = [];
152
+ const sourceEntryIds: string[] = [];
153
+ for (const entry of entries) {
154
+ if (!entry.id || !isSourceRenderableEntry(entry)) continue;
155
+ const rendered = serializeBranchEntries([entry]);
156
+ if (!rendered.trim()) continue;
157
+ sourceEntryIds.push(entry.id);
158
+ blocks.push(`[Source entry id: ${entry.id}]\n${rendered}`);
159
+ }
160
+ return { text: blocks.join("\n\n"), sourceEntryIds };
161
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Ledger custom-type vocabulary for observational memory.
3
+ */
4
+
5
+ /** Observer output committed by the orchestrator; the buffer tier. */
6
+ export const OM_OBSERVATIONS_RECORDED = "om.observations.recorded";
7
+ /** Promotion tombstones written by the orchestrator after a consolidator run (Phase B). */
8
+ export const OM_OBSERVATIONS_DROPPED = "om.observations.dropped";
9
+ /** Compaction details type stamped into the compaction entry's `details`. */
10
+ export const OM_FOLDED = "om.folded";
11
+ /** Per-session on/off gate state. When absent, defaultEnabled applies. */
12
+ export const OM_ENABLED = "om.enabled";
13
+ /**
14
+ * Per-worker cost record (one per finished worker run). The orchestrator appends these from
15
+ * pi's built-in `usage.cost.total`, reported back by the worker extension via the cost file.
16
+ * Summed across the WHOLE session (every branch), so spend never rolls back under /tree.
17
+ */
18
+ export const OM_COST = "om.cost";
19
+ /**
20
+ * Synthetic continuation message used to resume the agent loop after a mid-run compaction.
21
+ */
22
+ export const OM_RESUME = "om.resume";
23
+
24
+ export type Entry = {
25
+ type: string;
26
+ id: string;
27
+ timestamp?: string;
28
+ message?: unknown;
29
+ content?: unknown;
30
+ customType?: string;
31
+ summary?: unknown;
32
+ fromId?: string;
33
+ data?: unknown;
34
+ details?: unknown;
35
+ firstKeptEntryId?: string;
36
+ };
37
+
38
+ /**
39
+ * Minimal observation unit.
40
+ * - `timestamp`: the orchestrator-assigned precise, unique id-timestamp
41
+ * ("YYYY-MM-DDTHH:MM:SS" with an optional ".NN" disambiguator). Doubles as the id.
42
+ * - `content`: single-line plain prose.
43
+ * - `tokenCount`: computed in code (never by the model).
44
+ */
45
+ export type Observation = {
46
+ timestamp: string;
47
+ content: string;
48
+ tokenCount: number;
49
+ };
50
+
51
+ export type ObservationsRecordedEntryData = {
52
+ observations: Observation[];
53
+ coversUpToId: string;
54
+ };
55
+
56
+ export type ObservationsDroppedEntryData = {
57
+ observationTimestamps: string[];
58
+ coversUpToId: string;
59
+ };
60
+
61
+ export type CostEntryData = {
62
+ costUsd: number;
63
+ role: "observer" | "consolidator";
64
+ runId: string;
65
+ };
66
+
67
+ /** Stamped into the compaction entry's `details` so a future visible projection can read it back. */
68
+ export type MemoryDetails = {
69
+ type: typeof OM_FOLDED;
70
+ version: 1;
71
+ observations: Observation[];
72
+ };
73
+
74
+ export type MemoryCustomType = typeof OM_OBSERVATIONS_RECORDED | typeof OM_OBSERVATIONS_DROPPED;
75
+
76
+ export function isNonEmptyString(value: unknown): value is string {
77
+ return typeof value === "string" && value.length > 0;
78
+ }
79
+
80
+ export function isNonEmptyStringArray(value: unknown): value is string[] {
81
+ return Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString);
82
+ }
83
+
84
+ function isTokenCount(value: unknown): value is number {
85
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
86
+ }
87
+
88
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
89
+ return !!value && typeof value === "object";
90
+ }
91
+
92
+ export function isObservation(value: unknown): value is Observation {
93
+ if (!isPlainRecord(value)) return false;
94
+ return (
95
+ isNonEmptyString(value.timestamp) &&
96
+ isNonEmptyString(value.content) &&
97
+ !/\r|\n/.test(value.content) &&
98
+ isTokenCount(value.tokenCount)
99
+ );
100
+ }
101
+
102
+ export function isObservationsRecordedData(value: unknown): value is ObservationsRecordedEntryData {
103
+ if (!isPlainRecord(value)) return false;
104
+ return (
105
+ Array.isArray(value.observations) &&
106
+ value.observations.length > 0 &&
107
+ value.observations.every(isObservation) &&
108
+ isNonEmptyString(value.coversUpToId)
109
+ );
110
+ }
111
+
112
+ export function isObservationsDroppedData(value: unknown): value is ObservationsDroppedEntryData {
113
+ if (!isPlainRecord(value)) return false;
114
+ return isNonEmptyStringArray(value.observationTimestamps) && isNonEmptyString(value.coversUpToId);
115
+ }
116
+
117
+ export function isMemoryDetails(value: unknown): value is MemoryDetails {
118
+ if (!isPlainRecord(value)) return false;
119
+ return (
120
+ value.type === OM_FOLDED &&
121
+ value.version === 1 &&
122
+ Array.isArray(value.observations) &&
123
+ value.observations.every(isObservation)
124
+ );
125
+ }
126
+
127
+ export function isCostData(value: unknown): value is CostEntryData {
128
+ if (!isPlainRecord(value)) return false;
129
+ return (
130
+ typeof value.costUsd === "number" &&
131
+ Number.isFinite(value.costUsd) &&
132
+ value.costUsd >= 0 &&
133
+ (value.role === "observer" || value.role === "consolidator") &&
134
+ isNonEmptyString(value.runId)
135
+ );
136
+ }
137
+
138
+ export function isObservationsRecordedEntry(
139
+ entry: Entry,
140
+ ): entry is Entry & { type: "custom"; customType: typeof OM_OBSERVATIONS_RECORDED; data: ObservationsRecordedEntryData } {
141
+ return (
142
+ entry.type === "custom" &&
143
+ entry.customType === OM_OBSERVATIONS_RECORDED &&
144
+ isObservationsRecordedData(entry.data)
145
+ );
146
+ }
147
+
148
+ export function isObservationsDroppedEntry(
149
+ entry: Entry,
150
+ ): entry is Entry & { type: "custom"; customType: typeof OM_OBSERVATIONS_DROPPED; data: ObservationsDroppedEntryData } {
151
+ return (
152
+ entry.type === "custom" &&
153
+ entry.customType === OM_OBSERVATIONS_DROPPED &&
154
+ isObservationsDroppedData(entry.data)
155
+ );
156
+ }
157
+
158
+ export function isCostEntry(
159
+ entry: Entry,
160
+ ): entry is Entry & { type: "custom"; customType: typeof OM_COST; data: CostEntryData } {
161
+ if (entry.type !== "custom" || entry.customType !== OM_COST) return false;
162
+ const data = entry.data as Record<string, unknown> | undefined;
163
+ return (
164
+ !!data &&
165
+ typeof data.costUsd === "number" &&
166
+ Number.isFinite(data.costUsd as number) &&
167
+ (data.costUsd as number) >= 0
168
+ );
169
+ }
170
+
171
+ /**
172
+ * Sum every `om.cost` entry across the WHOLE session. Callers MUST pass all entries
173
+ * (`getEntries()`), NOT a single branch (`getBranch()`).
174
+ */
175
+ export function sumSessionCost(allEntries: Entry[]): { costUsd: number; runs: number } {
176
+ let costUsd = 0;
177
+ let runs = 0;
178
+ for (const entry of allEntries) {
179
+ if (isCostEntry(entry)) {
180
+ costUsd += (entry.data as CostEntryData).costUsd;
181
+ runs += 1;
182
+ }
183
+ }
184
+ return { costUsd, runs };
185
+ }
186
+
187
+ export function buildObservationsRecordedData(
188
+ observations: Observation[],
189
+ coversUpToId: string,
190
+ ): ObservationsRecordedEntryData | undefined {
191
+ if (observations.length === 0 || !isNonEmptyString(coversUpToId)) return undefined;
192
+ return { observations, coversUpToId };
193
+ }
194
+
195
+ export function buildObservationsDroppedData(
196
+ observationTimestamps: string[],
197
+ coversUpToId: string,
198
+ ): ObservationsDroppedEntryData | undefined {
199
+ if (observationTimestamps.length === 0 || !isNonEmptyString(coversUpToId)) return undefined;
200
+ return { observationTimestamps, coversUpToId };
201
+ }