@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,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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Topic } from "./paths.ts";
|
|
2
|
+
|
|
3
|
+
function summaryOf(topic: Topic): string {
|
|
4
|
+
const s = (topic.summary ?? "").trim();
|
|
5
|
+
return s.length > 0 ? s : "(no summary)";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function titleOf(topic: Topic): string {
|
|
9
|
+
const t = (topic.title ?? "").trim();
|
|
10
|
+
return t.length > 0 ? t : topic.filename;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The on-disk INDEX.md content. Orchestrator-owned; the consolidator never writes it. */
|
|
14
|
+
export function renderIndexFile(topics: Topic[]): string {
|
|
15
|
+
const parts: string[] = ["# Memory index", ""];
|
|
16
|
+
if (topics.length === 0) {
|
|
17
|
+
parts.push("_No topics yet._");
|
|
18
|
+
return `${parts.join("\n")}\n`;
|
|
19
|
+
}
|
|
20
|
+
parts.push("Durable memory topics for this project. Read a file for its full current state.", "");
|
|
21
|
+
for (const topic of topics) {
|
|
22
|
+
const updated = topic.updated ? ` · updated ${topic.updated}` : "";
|
|
23
|
+
parts.push(`## ${titleOf(topic)}`);
|
|
24
|
+
parts.push(`- \`${topic.path}\`${updated}`);
|
|
25
|
+
parts.push(`- ${summaryOf(topic)}`);
|
|
26
|
+
parts.push("");
|
|
27
|
+
}
|
|
28
|
+
return `${parts.join("\n").trimEnd()}\n`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The compaction injection block's memory-map section. Returns undefined when there are no topics.
|
|
33
|
+
*/
|
|
34
|
+
export function renderMemoryMap(topics: Topic[]): string | undefined {
|
|
35
|
+
if (topics.length === 0) return undefined;
|
|
36
|
+
const lines: string[] = [
|
|
37
|
+
"## Memory map",
|
|
38
|
+
"Durable long-term notes live in `.memory/`. Read a file when a topic below looks relevant; these summaries are intentionally terse.",
|
|
39
|
+
];
|
|
40
|
+
for (const topic of topics) {
|
|
41
|
+
const updated = topic.updated ? ` (updated ${topic.updated})` : "";
|
|
42
|
+
lines.push(`- \`${topic.path}\` — ${summaryOf(topic)}${updated}`);
|
|
43
|
+
}
|
|
44
|
+
return lines.join("\n");
|
|
45
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const INDEX_FILENAME = "INDEX.md";
|
|
5
|
+
export const JOURNEY_FILENAME = "JOURNEY.md";
|
|
6
|
+
|
|
7
|
+
/** The project-level `.memory/` base. Per-session roots live one level below it. */
|
|
8
|
+
export function memoryBaseDir(cwd: string): string {
|
|
9
|
+
return join(cwd, ".memory");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The per-session memory root: `.memory/<sessionId>/`.
|
|
14
|
+
*/
|
|
15
|
+
export function sessionMemoryRoot(cwd: string, sessionId: string): string {
|
|
16
|
+
return join(memoryBaseDir(cwd), sessionId);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function indexPath(root: string): string {
|
|
20
|
+
return join(root, INDEX_FILENAME);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function journeyPath(root: string): string {
|
|
24
|
+
return join(root, JOURNEY_FILENAME);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read `.memory/JOURNEY.md` body, trimmed. Returns undefined when missing or effectively empty. */
|
|
28
|
+
export function readJourney(root: string): string | undefined {
|
|
29
|
+
const path = journeyPath(root);
|
|
30
|
+
if (!existsSync(path)) return undefined;
|
|
31
|
+
try {
|
|
32
|
+
const body = readFileSync(path, "utf-8").trim();
|
|
33
|
+
return body.length > 0 ? body : undefined;
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Atomic write (temp + rename). Creates parent dirs as needed. */
|
|
40
|
+
export function atomicWrite(path: string, content: string): void {
|
|
41
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
42
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
43
|
+
writeFileSync(tmp, content, "utf-8");
|
|
44
|
+
renameSync(tmp, path);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a (possibly relative) path and confirm it stays inside `.memory/`.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveWithinMemory(root: string, requestedPath: string): string | undefined {
|
|
51
|
+
const base = resolve(root);
|
|
52
|
+
const abs = resolve(base, requestedPath);
|
|
53
|
+
const rel = relative(base, abs);
|
|
54
|
+
if (rel === "" || rel === ".") return abs;
|
|
55
|
+
if (rel.startsWith("..") || resolve(base, rel) !== abs) return undefined;
|
|
56
|
+
return abs;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type TopicFrontMatter = {
|
|
60
|
+
id?: string;
|
|
61
|
+
title?: string;
|
|
62
|
+
summary?: string;
|
|
63
|
+
updated?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type Topic = TopicFrontMatter & {
|
|
67
|
+
/** Path relative to the project root, e.g. ".memory/<sessionId>/auth.md". */
|
|
68
|
+
path: string;
|
|
69
|
+
/** Bare filename, e.g. "auth.md". */
|
|
70
|
+
filename: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const FRONT_MATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse leading YAML-ish front-matter.
|
|
77
|
+
*/
|
|
78
|
+
export function parseFrontMatter(content: string): { front: TopicFrontMatter; body: string } {
|
|
79
|
+
const match = FRONT_MATTER_RE.exec(content);
|
|
80
|
+
if (!match) return { front: {}, body: content };
|
|
81
|
+
const front: TopicFrontMatter = {};
|
|
82
|
+
for (const line of match[1].split("\n")) {
|
|
83
|
+
const idx = line.indexOf(":");
|
|
84
|
+
if (idx < 0) continue;
|
|
85
|
+
const key = line.slice(0, idx).trim();
|
|
86
|
+
let value = line.slice(idx + 1).trim();
|
|
87
|
+
if (
|
|
88
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
89
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
90
|
+
) {
|
|
91
|
+
value = value.slice(1, -1);
|
|
92
|
+
}
|
|
93
|
+
if (key === "id" || key === "title" || key === "summary" || key === "updated") {
|
|
94
|
+
front[key] = value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { front, body: content.slice(match[0].length) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* List parsed topic files under a session memory root, sorted by filename.
|
|
102
|
+
*/
|
|
103
|
+
export function listTopics(root: string): Topic[] {
|
|
104
|
+
if (!existsSync(root)) return [];
|
|
105
|
+
const cwd = resolve(root, "..", "..");
|
|
106
|
+
const topics: Topic[] = [];
|
|
107
|
+
for (const filename of readdirSync(root)) {
|
|
108
|
+
if (!filename.endsWith(".md") || filename === INDEX_FILENAME || filename === JOURNEY_FILENAME) continue;
|
|
109
|
+
let content: string;
|
|
110
|
+
try {
|
|
111
|
+
content = readFileSync(join(root, filename), "utf-8");
|
|
112
|
+
} catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const { front } = parseFrontMatter(content);
|
|
116
|
+
topics.push({ ...front, path: relative(cwd, join(root, filename)), filename });
|
|
117
|
+
}
|
|
118
|
+
topics.sort((a, b) => (a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0));
|
|
119
|
+
return topics;
|
|
120
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { cpSync, existsSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
2
|
+
import { basename, sep } from "node:path";
|
|
3
|
+
import { sessionMemoryRoot } from "./paths.ts";
|
|
4
|
+
|
|
5
|
+
type SessionCtx = {
|
|
6
|
+
cwd: string;
|
|
7
|
+
sessionManager: {
|
|
8
|
+
getSessionId: () => string;
|
|
9
|
+
getHeader?: () => { id?: string; cwd?: string; parentSession?: string } | null | undefined;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Read a session file's header id (first JSONL line). Undefined on any parse/IO failure. */
|
|
14
|
+
function readSessionHeaderId(file: string): string | undefined {
|
|
15
|
+
try {
|
|
16
|
+
const firstLine = readFileSync(file, "utf-8").split("\n", 1)[0] ?? "";
|
|
17
|
+
const header = JSON.parse(firstLine) as { type?: string; id?: string } | undefined;
|
|
18
|
+
return typeof header?.id === "string" ? header.id : undefined;
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the parent session's memory root for fork/clone seeding.
|
|
26
|
+
*/
|
|
27
|
+
function parentMemoryRoot(ctx: SessionCtx): string | undefined {
|
|
28
|
+
const parentFile = ctx.sessionManager.getHeader?.()?.parentSession;
|
|
29
|
+
if (!parentFile) return undefined;
|
|
30
|
+
const parentId = readSessionHeaderId(parentFile);
|
|
31
|
+
if (!parentId) return undefined;
|
|
32
|
+
const root = sessionMemoryRoot(ctx.cwd, parentId);
|
|
33
|
+
return existsSync(root) ? root : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True for any path inside a `.runs` directory (transient IPC; never seeded). */
|
|
37
|
+
function isRunsPath(p: string): boolean {
|
|
38
|
+
return basename(p) === ".runs" || p.includes(`${sep}.runs${sep}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve this session's `.memory/<sessionId>/` root, seeding it from the parent session on first touch.
|
|
43
|
+
*/
|
|
44
|
+
export function ensureSessionMemory(ctx: SessionCtx): string {
|
|
45
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
46
|
+
const root = sessionMemoryRoot(ctx.cwd, sessionId);
|
|
47
|
+
if (existsSync(root)) return root;
|
|
48
|
+
|
|
49
|
+
const parent = parentMemoryRoot(ctx);
|
|
50
|
+
if (parent) {
|
|
51
|
+
const tmp = `${root}.seed-tmp-${process.pid}-${Date.now()}`;
|
|
52
|
+
try {
|
|
53
|
+
cpSync(parent, tmp, { recursive: true, filter: (src: string) => !isRunsPath(src) });
|
|
54
|
+
renameSync(tmp, root);
|
|
55
|
+
} catch {
|
|
56
|
+
try {
|
|
57
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
58
|
+
} catch {
|
|
59
|
+
// best-effort cleanup
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return root;
|
|
64
|
+
}
|