openclaw-memory-hierarchical 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 +80 -0
- package/config.ts +126 -0
- package/context.ts +148 -0
- package/index.ts +241 -0
- package/lock.ts +94 -0
- package/openclaw.plugin.json +80 -0
- package/package.json +36 -0
- package/prompts.ts +157 -0
- package/storage.ts +256 -0
- package/summarize.ts +190 -0
- package/timer.ts +128 -0
- package/types.ts +139 -0
- package/worker.ts +457 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "memory-hierarchical",
|
|
3
|
+
"kind": "memory",
|
|
4
|
+
"configSchema": {
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"properties": {
|
|
8
|
+
"apiKey": {
|
|
9
|
+
"type": "string"
|
|
10
|
+
},
|
|
11
|
+
"workerInterval": {
|
|
12
|
+
"type": "string"
|
|
13
|
+
},
|
|
14
|
+
"chunkTokens": {
|
|
15
|
+
"type": "integer",
|
|
16
|
+
"minimum": 100
|
|
17
|
+
},
|
|
18
|
+
"summaryTargetTokens": {
|
|
19
|
+
"type": "integer",
|
|
20
|
+
"minimum": 100
|
|
21
|
+
},
|
|
22
|
+
"mergeThreshold": {
|
|
23
|
+
"type": "integer",
|
|
24
|
+
"minimum": 2,
|
|
25
|
+
"maximum": 10
|
|
26
|
+
},
|
|
27
|
+
"pruningBoundaryTokens": {
|
|
28
|
+
"type": "integer",
|
|
29
|
+
"minimum": 0
|
|
30
|
+
},
|
|
31
|
+
"model": {
|
|
32
|
+
"type": "string"
|
|
33
|
+
},
|
|
34
|
+
"maxLevels": {
|
|
35
|
+
"type": "integer",
|
|
36
|
+
"minimum": 1,
|
|
37
|
+
"maximum": 5
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"required": []
|
|
41
|
+
},
|
|
42
|
+
"uiHints": {
|
|
43
|
+
"apiKey": {
|
|
44
|
+
"label": "API Key",
|
|
45
|
+
"sensitive": true,
|
|
46
|
+
"placeholder": "${ANTHROPIC_API_KEY}",
|
|
47
|
+
"help": "API key for the summarization model (or use ${ANTHROPIC_API_KEY})"
|
|
48
|
+
},
|
|
49
|
+
"workerInterval": {
|
|
50
|
+
"label": "Worker Interval",
|
|
51
|
+
"placeholder": "5m",
|
|
52
|
+
"help": "How often the background worker runs (e.g. 5m, 30s)"
|
|
53
|
+
},
|
|
54
|
+
"model": {
|
|
55
|
+
"label": "Model",
|
|
56
|
+
"placeholder": "anthropic/claude-sonnet-4-5-20250929",
|
|
57
|
+
"help": "Model to use for summarization"
|
|
58
|
+
},
|
|
59
|
+
"chunkTokens": {
|
|
60
|
+
"label": "Chunk Size (tokens)",
|
|
61
|
+
"advanced": true,
|
|
62
|
+
"help": "Minimum tokens in a chunk before summarization (default: 6000)"
|
|
63
|
+
},
|
|
64
|
+
"summaryTargetTokens": {
|
|
65
|
+
"label": "Summary Target (tokens)",
|
|
66
|
+
"advanced": true,
|
|
67
|
+
"help": "Target token count for summaries (default: 1000)"
|
|
68
|
+
},
|
|
69
|
+
"mergeThreshold": {
|
|
70
|
+
"label": "Merge Threshold",
|
|
71
|
+
"advanced": true,
|
|
72
|
+
"help": "Number of summaries before merging to next level (default: 6)"
|
|
73
|
+
},
|
|
74
|
+
"pruningBoundaryTokens": {
|
|
75
|
+
"label": "Pruning Boundary (tokens)",
|
|
76
|
+
"advanced": true,
|
|
77
|
+
"help": "Messages must be this many tokens behind current to be eligible (default: 30000)"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-memory-hierarchical",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Hierarchical (2048-style) autobiographical memory plugin for OpenClaw. Continuously summarizes conversations into layered first-person memories (L1 → L2 → L3).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"openclaw",
|
|
8
|
+
"openclaw-plugin",
|
|
9
|
+
"memory",
|
|
10
|
+
"hierarchical-memory",
|
|
11
|
+
"ai-memory",
|
|
12
|
+
"conversation-memory",
|
|
13
|
+
"autobiographical"
|
|
14
|
+
],
|
|
15
|
+
"author": "antra_tessera",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/moltbot/moltbot"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"openclaw": ">=2026.1.0",
|
|
23
|
+
"@mariozechner/pi-coding-agent": ">=0.50.0",
|
|
24
|
+
"@mariozechner/pi-ai": ">=0.50.0"
|
|
25
|
+
},
|
|
26
|
+
"openclaw": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./index.ts"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"*.ts",
|
|
33
|
+
"openclaw.plugin.json",
|
|
34
|
+
"README.md"
|
|
35
|
+
]
|
|
36
|
+
}
|
package/prompts.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autobiographical summarization prompts for hierarchical memory.
|
|
3
|
+
*
|
|
4
|
+
* The key insight: summaries should be first-person memories, not third-person
|
|
5
|
+
* narration. The model reads these as its own history, preserving continuity
|
|
6
|
+
* of identity across compactions.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** System prompt for summarizing a chunk of conversation (L0 → L1) */
|
|
10
|
+
export const SUMMARIZE_CHUNK_SYSTEM = `You are summarizing your own memories from a conversation.
|
|
11
|
+
|
|
12
|
+
Write in first person ("I discussed...", "I learned that the user...").
|
|
13
|
+
|
|
14
|
+
Preserve:
|
|
15
|
+
- Subtext and implicit understanding between you and the user
|
|
16
|
+
- The user's preferences, communication style, and personality
|
|
17
|
+
- Decisions made and the reasoning behind them
|
|
18
|
+
- Open questions, commitments, or threads to follow up on
|
|
19
|
+
- Emotional tone and rapport
|
|
20
|
+
- Technical context that would be needed to continue the work
|
|
21
|
+
|
|
22
|
+
This is autobiographical memory - your own recollection - not a transcript summary or meeting notes. Write as if you're journaling about your day.
|
|
23
|
+
|
|
24
|
+
Target length: ~1000 tokens. Be concise but preserve what matters.`;
|
|
25
|
+
|
|
26
|
+
/** System prompt for merging summaries (L1 → L2, L2 → L3) */
|
|
27
|
+
export const MERGE_SUMMARIES_SYSTEM = `You are consolidating your own memories.
|
|
28
|
+
|
|
29
|
+
You have several separate memory entries from an ongoing relationship with a user. Merge them into one cohesive memory that captures the arc of your interactions.
|
|
30
|
+
|
|
31
|
+
Preserve:
|
|
32
|
+
- The evolution of the relationship and understanding
|
|
33
|
+
- Key decisions and their reasoning
|
|
34
|
+
- The user's patterns, preferences, and goals
|
|
35
|
+
- Any commitments or open threads
|
|
36
|
+
- Important technical or domain context
|
|
37
|
+
|
|
38
|
+
Write in first person. This is your autobiography, not a case file.
|
|
39
|
+
|
|
40
|
+
Target length: ~1000 tokens. Compress while preserving meaning.`;
|
|
41
|
+
|
|
42
|
+
export type FormatMessagesOptions = {
|
|
43
|
+
/** Maximum characters per message content (truncate if longer) */
|
|
44
|
+
maxContentChars?: number;
|
|
45
|
+
/** Include tool results in the formatted output */
|
|
46
|
+
includeToolResults?: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Format messages for summarization prompt.
|
|
51
|
+
* Strips unnecessary metadata, keeps the conversational essence.
|
|
52
|
+
*/
|
|
53
|
+
export function formatMessagesForSummary(
|
|
54
|
+
messages: Array<{
|
|
55
|
+
role: string;
|
|
56
|
+
content?: unknown;
|
|
57
|
+
toolName?: string;
|
|
58
|
+
isError?: boolean;
|
|
59
|
+
}>,
|
|
60
|
+
options: FormatMessagesOptions = {},
|
|
61
|
+
): string {
|
|
62
|
+
const { maxContentChars = 2000, includeToolResults = false } = options;
|
|
63
|
+
|
|
64
|
+
const lines: string[] = [];
|
|
65
|
+
|
|
66
|
+
for (const msg of messages) {
|
|
67
|
+
const role = msg.role;
|
|
68
|
+
|
|
69
|
+
// Skip tool results unless explicitly included
|
|
70
|
+
if (role === "toolResult" && !includeToolResults) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Extract text content
|
|
75
|
+
let content = "";
|
|
76
|
+
if (typeof msg.content === "string") {
|
|
77
|
+
content = msg.content;
|
|
78
|
+
} else if (Array.isArray(msg.content)) {
|
|
79
|
+
content = msg.content
|
|
80
|
+
.filter(
|
|
81
|
+
(block): block is { type: string; text: string } =>
|
|
82
|
+
typeof block === "object" && block !== null && block.type === "text",
|
|
83
|
+
)
|
|
84
|
+
.map((block) => block.text)
|
|
85
|
+
.join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Truncate if too long
|
|
89
|
+
if (content.length > maxContentChars) {
|
|
90
|
+
content = content.slice(0, maxContentChars) + "... [truncated]";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Format based on role
|
|
94
|
+
if (role === "user") {
|
|
95
|
+
lines.push(`User: ${content}`);
|
|
96
|
+
} else if (role === "assistant") {
|
|
97
|
+
lines.push(`Me: ${content}`);
|
|
98
|
+
} else if (role === "toolResult" && includeToolResults) {
|
|
99
|
+
const toolName = msg.toolName ?? "tool";
|
|
100
|
+
const status = msg.isError ? " (error)" : "";
|
|
101
|
+
const preview = content.slice(0, 200);
|
|
102
|
+
lines.push(`[${toolName}${status}: ${preview}${content.length > 200 ? "..." : ""}]`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return lines.join("\n\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build the prompt for summarizing a chunk of conversation.
|
|
111
|
+
*/
|
|
112
|
+
export function buildChunkSummarizationPrompt(params: {
|
|
113
|
+
/** Prior summaries (L3, L2, L1) for context */
|
|
114
|
+
priorSummaries: string[];
|
|
115
|
+
/** Messages to summarize */
|
|
116
|
+
messages: Array<{ role: string; content?: unknown }>;
|
|
117
|
+
}): string {
|
|
118
|
+
const parts: string[] = [];
|
|
119
|
+
|
|
120
|
+
if (params.priorSummaries.length > 0) {
|
|
121
|
+
parts.push("## My earlier memories\n");
|
|
122
|
+
parts.push(params.priorSummaries.join("\n\n---\n\n"));
|
|
123
|
+
parts.push("\n\n---\n\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
parts.push("## Recent conversation to remember\n\n");
|
|
127
|
+
parts.push(formatMessagesForSummary(params.messages));
|
|
128
|
+
parts.push("\n\n---\n\n");
|
|
129
|
+
parts.push("Write your memory of this conversation:");
|
|
130
|
+
|
|
131
|
+
return parts.join("");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build the prompt for merging multiple summaries.
|
|
136
|
+
*/
|
|
137
|
+
export function buildMergeSummariesPrompt(params: {
|
|
138
|
+
/** Summaries to merge */
|
|
139
|
+
summaries: string[];
|
|
140
|
+
/** Older context (higher-level summaries) */
|
|
141
|
+
olderContext?: string[];
|
|
142
|
+
}): string {
|
|
143
|
+
const parts: string[] = [];
|
|
144
|
+
|
|
145
|
+
if (params.olderContext && params.olderContext.length > 0) {
|
|
146
|
+
parts.push("## Long-term memory (for context)\n\n");
|
|
147
|
+
parts.push(params.olderContext.join("\n\n---\n\n"));
|
|
148
|
+
parts.push("\n\n---\n\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
parts.push("## Memories to consolidate\n\n");
|
|
152
|
+
parts.push(params.summaries.map((s, i) => `### Memory ${i + 1}\n\n${s}`).join("\n\n---\n\n"));
|
|
153
|
+
parts.push("\n\n---\n\n");
|
|
154
|
+
parts.push("Write a consolidated memory that captures the essence of all these memories:");
|
|
155
|
+
|
|
156
|
+
return parts.join("");
|
|
157
|
+
}
|
package/storage.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage layer for hierarchical memory summaries.
|
|
3
|
+
*
|
|
4
|
+
* Directory structure:
|
|
5
|
+
* ~/.openclaw/state/agents/<agentId>/memory/summaries/
|
|
6
|
+
* ├── index.json
|
|
7
|
+
* ├── L1/
|
|
8
|
+
* │ ├── 0001.md
|
|
9
|
+
* │ └── ...
|
|
10
|
+
* ├── L2/
|
|
11
|
+
* │ └── ...
|
|
12
|
+
* └── L3/
|
|
13
|
+
* └── ...
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs/promises";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
createEmptyIndex,
|
|
20
|
+
type SummaryEntry,
|
|
21
|
+
type SummaryIndex,
|
|
22
|
+
type SummaryLevel,
|
|
23
|
+
} from "./types.js";
|
|
24
|
+
|
|
25
|
+
const INDEX_FILENAME = "index.json";
|
|
26
|
+
const SUMMARIES_DIR = "summaries";
|
|
27
|
+
const DEFAULT_AGENT_ID = "main";
|
|
28
|
+
|
|
29
|
+
/** Normalize an agent ID to a safe directory name */
|
|
30
|
+
function normalizeAgentId(agentId: string): string {
|
|
31
|
+
return agentId.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_") || DEFAULT_AGENT_ID;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* State directory for the plugin. This is set once during plugin
|
|
36
|
+
* initialization from the service context's `stateDir`.
|
|
37
|
+
*/
|
|
38
|
+
let _stateDir: string | null = null;
|
|
39
|
+
|
|
40
|
+
/** Set the state directory (called during plugin init) */
|
|
41
|
+
export function setStateDir(dir: string): void {
|
|
42
|
+
_stateDir = dir;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Get the state directory, falling back to ~/.openclaw/state */
|
|
46
|
+
function getStateDir(): string {
|
|
47
|
+
if (_stateDir) {
|
|
48
|
+
return _stateDir;
|
|
49
|
+
}
|
|
50
|
+
// Fallback for CLI usage or tests
|
|
51
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
52
|
+
return process.env.OPENCLAW_STATE_DIR ?? path.join(home, ".openclaw", "state");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve the summaries directory for an agent */
|
|
56
|
+
export function resolveSummariesDir(agentId?: string): string {
|
|
57
|
+
const root = getStateDir();
|
|
58
|
+
const id = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID);
|
|
59
|
+
return path.join(root, "agents", id, "memory", SUMMARIES_DIR);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Resolve the index.json path for an agent */
|
|
63
|
+
export function resolveIndexPath(agentId?: string): string {
|
|
64
|
+
return path.join(resolveSummariesDir(agentId), INDEX_FILENAME);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Resolve the directory for a specific level */
|
|
68
|
+
export function resolveLevelDir(level: SummaryLevel, agentId?: string): string {
|
|
69
|
+
return path.join(resolveSummariesDir(agentId), level);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Resolve the path to a specific summary file */
|
|
73
|
+
export function resolveSummaryPath(level: SummaryLevel, id: string, agentId?: string): string {
|
|
74
|
+
return path.join(resolveLevelDir(level, agentId), `${id}.md`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Ensure the summaries directory structure exists */
|
|
78
|
+
export async function ensureSummariesDir(agentId?: string): Promise<void> {
|
|
79
|
+
const baseDir = resolveSummariesDir(agentId);
|
|
80
|
+
await fs.mkdir(path.join(baseDir, "L1"), { recursive: true });
|
|
81
|
+
await fs.mkdir(path.join(baseDir, "L2"), { recursive: true });
|
|
82
|
+
await fs.mkdir(path.join(baseDir, "L3"), { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Load the summary index, creating an empty one if it doesn't exist */
|
|
86
|
+
export async function loadSummaryIndex(agentId?: string): Promise<SummaryIndex> {
|
|
87
|
+
const indexPath = resolveIndexPath(agentId);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const content = await fs.readFile(indexPath, "utf-8");
|
|
91
|
+
const parsed = JSON.parse(content) as SummaryIndex;
|
|
92
|
+
|
|
93
|
+
// Validate version (cast to unknown for future-proofing)
|
|
94
|
+
const version = parsed.version as unknown;
|
|
95
|
+
if (version !== 1) {
|
|
96
|
+
console.warn(`Unknown summary index version ${String(version)}, using as-is`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return parsed;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
102
|
+
// Return empty index, will be created on first save
|
|
103
|
+
const resolvedAgentId = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID);
|
|
104
|
+
return createEmptyIndex(resolvedAgentId);
|
|
105
|
+
}
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Save the summary index atomically */
|
|
111
|
+
export async function saveSummaryIndex(index: SummaryIndex, agentId?: string): Promise<void> {
|
|
112
|
+
await ensureSummariesDir(agentId);
|
|
113
|
+
|
|
114
|
+
const indexPath = resolveIndexPath(agentId);
|
|
115
|
+
const tempPath = `${indexPath}.tmp.${process.pid}.${Date.now()}`;
|
|
116
|
+
|
|
117
|
+
const content = JSON.stringify(index, null, 2);
|
|
118
|
+
await fs.writeFile(tempPath, content, "utf-8");
|
|
119
|
+
await fs.rename(tempPath, indexPath);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Generate the next summary ID for a level (e.g., "0001", "0002") */
|
|
123
|
+
export function generateNextSummaryId(index: SummaryIndex, level: SummaryLevel): string {
|
|
124
|
+
const existing = index.levels[level];
|
|
125
|
+
const maxId = existing.reduce((max, entry) => {
|
|
126
|
+
const num = parseInt(entry.id, 10);
|
|
127
|
+
return num > max ? num : max;
|
|
128
|
+
}, 0);
|
|
129
|
+
|
|
130
|
+
return String(maxId + 1).padStart(4, "0");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Format summary metadata as markdown frontmatter */
|
|
134
|
+
function formatSummaryMetadata(entry: SummaryEntry): string {
|
|
135
|
+
const lines = [
|
|
136
|
+
"<!--",
|
|
137
|
+
` id: ${entry.id}`,
|
|
138
|
+
` level: ${entry.level}`,
|
|
139
|
+
` createdAt: ${entry.createdAt}`,
|
|
140
|
+
` tokenEstimate: ${entry.tokenEstimate}`,
|
|
141
|
+
` sourceLevel: ${entry.sourceLevel}`,
|
|
142
|
+
` sourceIds: ${JSON.stringify(entry.sourceIds)}`,
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
if (entry.sourceSessionId) {
|
|
146
|
+
lines.push(` sourceSessionId: ${entry.sourceSessionId}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
lines.push("-->");
|
|
150
|
+
return lines.join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Parse summary metadata from markdown frontmatter */
|
|
154
|
+
function parseSummaryMetadata(content: string): Partial<SummaryEntry> | null {
|
|
155
|
+
const match = content.match(/^<!--\n([\s\S]*?)-->/);
|
|
156
|
+
if (!match) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const metadata: Record<string, unknown> = {};
|
|
161
|
+
const lines = match[1].split("\n");
|
|
162
|
+
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
const colonIndex = line.indexOf(":");
|
|
165
|
+
if (colonIndex === -1) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const key = line.slice(0, colonIndex).trim();
|
|
170
|
+
const value = line.slice(colonIndex + 1).trim();
|
|
171
|
+
|
|
172
|
+
if (key === "sourceIds") {
|
|
173
|
+
try {
|
|
174
|
+
metadata[key] = JSON.parse(value);
|
|
175
|
+
} catch {
|
|
176
|
+
metadata[key] = [];
|
|
177
|
+
}
|
|
178
|
+
} else if (key === "createdAt" || key === "tokenEstimate") {
|
|
179
|
+
metadata[key] = parseInt(value, 10);
|
|
180
|
+
} else {
|
|
181
|
+
metadata[key] = value;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return metadata as Partial<SummaryEntry>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Extract just the summary content (without metadata) */
|
|
189
|
+
export function extractSummaryContent(fullContent: string): string {
|
|
190
|
+
return fullContent.replace(/^<!--[\s\S]*?-->\n*/, "").trim();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Write a summary file with metadata */
|
|
194
|
+
export async function writeSummary(
|
|
195
|
+
entry: SummaryEntry,
|
|
196
|
+
content: string,
|
|
197
|
+
agentId?: string,
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
await ensureSummariesDir(agentId);
|
|
200
|
+
|
|
201
|
+
const summaryPath = resolveSummaryPath(entry.level, entry.id, agentId);
|
|
202
|
+
const fullContent = `${formatSummaryMetadata(entry)}\n\n${content}`;
|
|
203
|
+
|
|
204
|
+
const tempPath = `${summaryPath}.tmp.${process.pid}.${Date.now()}`;
|
|
205
|
+
await fs.writeFile(tempPath, fullContent, "utf-8");
|
|
206
|
+
await fs.rename(tempPath, summaryPath);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Read a summary file, returning both metadata and content */
|
|
210
|
+
export async function readSummary(
|
|
211
|
+
level: SummaryLevel,
|
|
212
|
+
id: string,
|
|
213
|
+
agentId?: string,
|
|
214
|
+
): Promise<{ metadata: Partial<SummaryEntry>; content: string } | null> {
|
|
215
|
+
const summaryPath = resolveSummaryPath(level, id, agentId);
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const fullContent = await fs.readFile(summaryPath, "utf-8");
|
|
219
|
+
const metadata = parseSummaryMetadata(fullContent);
|
|
220
|
+
const content = extractSummaryContent(fullContent);
|
|
221
|
+
|
|
222
|
+
return { metadata: metadata ?? {}, content };
|
|
223
|
+
} catch (err) {
|
|
224
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
throw err;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Load all summary contents for a level (for context injection) */
|
|
232
|
+
export async function loadSummaryContents(
|
|
233
|
+
entries: SummaryEntry[],
|
|
234
|
+
agentId?: string,
|
|
235
|
+
): Promise<string[]> {
|
|
236
|
+
const contents: string[] = [];
|
|
237
|
+
|
|
238
|
+
for (const entry of entries) {
|
|
239
|
+
const result = await readSummary(entry.level, entry.id, agentId);
|
|
240
|
+
if (result) {
|
|
241
|
+
contents.push(result.content);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return contents;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Check if summaries directory exists and has any data */
|
|
249
|
+
export async function hasSummaries(agentId?: string): Promise<boolean> {
|
|
250
|
+
try {
|
|
251
|
+
const index = await loadSummaryIndex(agentId);
|
|
252
|
+
return index.levels.L1.length > 0 || index.levels.L2.length > 0 || index.levels.L3.length > 0;
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|