@lebronj/pi-suite 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/LICENSE +21 -0
- package/README.md +86 -0
- package/extensions/pet.ts +1033 -0
- package/extensions/prompt-url-widget.ts +158 -0
- package/extensions/redraws.ts +24 -0
- package/extensions/snake.ts +343 -0
- package/extensions/tps.ts +47 -0
- package/package.json +69 -0
- package/prompts/cl.md +54 -0
- package/prompts/is.md +25 -0
- package/prompts/pr.md +37 -0
- package/prompts/wr.md +35 -0
- package/scripts/bootstrap.sh +95 -0
- package/skills/add-llm-provider.md +57 -0
- package/skills/image-to-editable-ppt-slide/SKILL.md +113 -0
- package/skills/image-to-editable-ppt-slide/scripts/generate_spec_template.py +91 -0
- package/skills/image-to-editable-ppt-slide/scripts/pptx_rebuilder.py +181 -0
- package/skills/leetcode-array/SKILL.md +40 -0
- package/skills/leetcode-array/problems/best_time_to_buy_and_sell_stock.py +19 -0
- package/skills/leetcode-array/problems/product_of_array_except_self.py +22 -0
- package/skills/leetcode-array/problems/two_sum.py +19 -0
- package/skills/pi-skill/SKILL.md +154 -0
- package/skills/weather.md +49 -0
- package/vendor/pi-memory/LICENSE +21 -0
- package/vendor/pi-memory/README.md +223 -0
- package/vendor/pi-memory/index.ts +2367 -0
- package/vendor/pi-memory/package.json +68 -0
- package/vendor/pi-memory/scripts/postinstall.cjs +44 -0
- package/vendor/pi-memory/src/cli.ts +79 -0
- package/vendor/pi-memory/src/curator-core/audit.ts +45 -0
- package/vendor/pi-memory/src/curator-core/curate.ts +90 -0
- package/vendor/pi-memory/src/curator-core/metadata.ts +55 -0
- package/vendor/pi-memory/src/curator-core/patch.ts +24 -0
- package/vendor/pi-memory/src/curator-core/policy.ts +77 -0
- package/vendor/pi-memory/src/curator-store/file-store.ts +51 -0
- package/vendor/pi-memory/src/curator-store/types.ts +21 -0
- package/vendor/pi-memory/src/index.ts +35 -0
- package/vendor/pi-memory/src/learning/candidates.ts +205 -0
- package/vendor/pi-memory/src/learning/memory.ts +144 -0
- package/vendor/pi-memory/src/learning/skills.ts +200 -0
- package/vendor/pi-memory/src/service-controller.ts +248 -0
- package/vendor/pi-memory/test/curate.test.ts +68 -0
- package/vendor/pi-memory/test/learning-candidates.test.ts +107 -0
- package/vendor/pi-memory/test/memory-promotions.test.ts +44 -0
- package/vendor/pi-memory/test/metadata.test.ts +17 -0
- package/vendor/pi-memory/test/skill-drafts.test.ts +57 -0
- package/vendor/pi-memory/test/transition-handoff.test.ts +86 -0
|
@@ -0,0 +1,2367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Extension with QMD-Powered Search
|
|
3
|
+
*
|
|
4
|
+
* Structured Markdown memory system with semantic search via qmd.
|
|
5
|
+
* Core memory tools (write/read/edit/scratchpad) work without qmd installed.
|
|
6
|
+
* The memory_search tool requires qmd for keyword, semantic, and hybrid search.
|
|
7
|
+
*
|
|
8
|
+
* Layout (under ~/.pi/agent/memory/):
|
|
9
|
+
* MEMORY.md — durable facts, decisions, and preferences
|
|
10
|
+
* USER.md — structured user profile and stable preferences
|
|
11
|
+
* STATE.md — current dated state, events, temporary facts, quotas
|
|
12
|
+
* REVIEW.md — review queue for stale or merge-candidate memories
|
|
13
|
+
* SCRATCHPAD.md — checklist of things to keep in mind / fix later
|
|
14
|
+
* daily/YYYY-MM-DD.md — daily append-only log (today + yesterday loaded at session start)
|
|
15
|
+
*
|
|
16
|
+
* Tools:
|
|
17
|
+
* memory_write — write to long-term, daily, user, state, or review memory
|
|
18
|
+
* memory_read — read any memory target or list daily logs
|
|
19
|
+
* memory_edit — add, replace, remove, replace_all, or compact structured entries
|
|
20
|
+
* scratchpad — add/check/uncheck/clear items on the scratchpad checklist
|
|
21
|
+
* memory_search — search across all memory files via qmd (keyword, semantic, or deep)
|
|
22
|
+
* memory_curate — run curator lifecycle rules immediately
|
|
23
|
+
*
|
|
24
|
+
* Context injection:
|
|
25
|
+
* - SCRATCHPAD.md + daily logs + USER.md + current STATE.md + MEMORY.md
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { type ExecFileOptions, execFile } from "node:child_process";
|
|
29
|
+
import * as fs from "node:fs";
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
import { complete, type Message, StringEnum } from "@earendil-works/pi-ai";
|
|
32
|
+
import {
|
|
33
|
+
convertToLlm,
|
|
34
|
+
type ExtensionAPI,
|
|
35
|
+
type ExtensionContext,
|
|
36
|
+
type SessionEntry,
|
|
37
|
+
serializeConversation,
|
|
38
|
+
} from "@earendil-works/pi-coding-agent";
|
|
39
|
+
import { Type } from "typebox";
|
|
40
|
+
import { JsonlAuditLog } from "./src/curator-core/audit.ts";
|
|
41
|
+
import { parseEntry as parseStructuredEntry, parseMetadata as parseStructuredMetadata, serializeMetadata } from "./src/curator-core/metadata.ts";
|
|
42
|
+
import { runMemoryCuratorOnce } from "./src/curator-core/curate.ts";
|
|
43
|
+
import {
|
|
44
|
+
REVIEW_CANDIDATE_KINDS,
|
|
45
|
+
REVIEW_CONFIDENCES,
|
|
46
|
+
REVIEW_TARGET_HINTS,
|
|
47
|
+
upsertReviewCandidate,
|
|
48
|
+
type ReviewCandidateInput,
|
|
49
|
+
} from "./src/learning/candidates.ts";
|
|
50
|
+
import { applyReviewLifecycle, approveMemoryPromotion, proposeMemoryPromotions, rejectReviewItem } from "./src/learning/memory.ts";
|
|
51
|
+
import { approveSkillDraft, listSkillDraftProposals, proposeSkillDrafts } from "./src/learning/skills.ts";
|
|
52
|
+
import { FileMemoryStore } from "./src/curator-store/file-store.ts";
|
|
53
|
+
import { disableCuratorService, enableCuratorService, getCuratorServiceStatus } from "./src/service-controller.ts";
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Paths (mutable for testing via _setBaseDir / _resetBaseDir)
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
type MemoryEnv = Partial<
|
|
61
|
+
Record<"PI_MEMORY_DIR" | "HOME" | "USERPROFILE" | "HOMEDRIVE" | "HOMEPATH", string | undefined>
|
|
62
|
+
> & {
|
|
63
|
+
[key: string]: string | undefined;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function resolveMemoryDir(env: MemoryEnv = process.env): string {
|
|
67
|
+
if (env.PI_MEMORY_DIR) return env.PI_MEMORY_DIR;
|
|
68
|
+
const home =
|
|
69
|
+
env.HOME ??
|
|
70
|
+
env.USERPROFILE ??
|
|
71
|
+
(env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined) ??
|
|
72
|
+
"~";
|
|
73
|
+
return path.join(home, ".pi", "agent", "memory");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let MEMORY_DIR = resolveMemoryDir();
|
|
77
|
+
let MEMORY_FILE = path.join(MEMORY_DIR, "MEMORY.md");
|
|
78
|
+
let USER_FILE = path.join(MEMORY_DIR, "USER.md");
|
|
79
|
+
let STATE_FILE = path.join(MEMORY_DIR, "STATE.md");
|
|
80
|
+
let REVIEW_FILE = path.join(MEMORY_DIR, "REVIEW.md");
|
|
81
|
+
let SCRATCHPAD_FILE = path.join(MEMORY_DIR, "SCRATCHPAD.md");
|
|
82
|
+
let DAILY_DIR = path.join(MEMORY_DIR, "daily");
|
|
83
|
+
let SKILL_DRAFTS_DIR = path.join(path.dirname(MEMORY_DIR), "skill-drafts");
|
|
84
|
+
|
|
85
|
+
/** Override base directory (for testing). */
|
|
86
|
+
export function _setBaseDir(baseDir: string) {
|
|
87
|
+
MEMORY_DIR = baseDir;
|
|
88
|
+
MEMORY_FILE = path.join(baseDir, "MEMORY.md");
|
|
89
|
+
USER_FILE = path.join(baseDir, "USER.md");
|
|
90
|
+
STATE_FILE = path.join(baseDir, "STATE.md");
|
|
91
|
+
REVIEW_FILE = path.join(baseDir, "REVIEW.md");
|
|
92
|
+
SCRATCHPAD_FILE = path.join(baseDir, "SCRATCHPAD.md");
|
|
93
|
+
DAILY_DIR = path.join(baseDir, "daily");
|
|
94
|
+
SKILL_DRAFTS_DIR = path.join(path.dirname(baseDir), "skill-drafts");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Reset to default paths (for testing). */
|
|
98
|
+
export function _resetBaseDir() {
|
|
99
|
+
_setBaseDir(resolveMemoryDir());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Utilities
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
export function ensureDirs() {
|
|
107
|
+
fs.mkdirSync(MEMORY_DIR, { recursive: true });
|
|
108
|
+
fs.mkdirSync(DAILY_DIR, { recursive: true });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function todayStr(): string {
|
|
112
|
+
const d = new Date();
|
|
113
|
+
return d.toISOString().slice(0, 10);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function yesterdayStr(): string {
|
|
117
|
+
const d = new Date();
|
|
118
|
+
d.setDate(d.getDate() - 1);
|
|
119
|
+
return d.toISOString().slice(0, 10);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function nowTimestamp(): string {
|
|
123
|
+
return new Date()
|
|
124
|
+
.toISOString()
|
|
125
|
+
.replace("T", " ")
|
|
126
|
+
.replace(/\.\d+Z$/, "");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function shortSessionId(sessionId: string): string {
|
|
130
|
+
return sessionId.slice(0, 8);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function readFileSafe(filePath: string): string | null {
|
|
134
|
+
try {
|
|
135
|
+
return fs.readFileSync(filePath, "utf-8");
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const DAILY_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
|
142
|
+
|
|
143
|
+
export function isValidDailyDate(date: string): boolean {
|
|
144
|
+
if (!DAILY_DATE_REGEX.test(date)) return false;
|
|
145
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
146
|
+
const parsed = new Date(Date.UTC(year, month - 1, day));
|
|
147
|
+
return parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function dailyPath(date: string): string {
|
|
151
|
+
if (!isValidDailyDate(date)) {
|
|
152
|
+
throw new Error(`Invalid daily date: ${date}. Expected YYYY-MM-DD.`);
|
|
153
|
+
}
|
|
154
|
+
return path.join(DAILY_DIR, `${date}.md`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
function structuredMemoryPath(target: StructuredMemoryTarget): string {
|
|
159
|
+
if (target === "memory") return MEMORY_FILE;
|
|
160
|
+
if (target === "user") return USER_FILE;
|
|
161
|
+
if (target === "state") return STATE_FILE;
|
|
162
|
+
return REVIEW_FILE;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function normalizeStructuredMemoryTarget(value: string | undefined, fallback: StructuredMemoryTarget = "memory"): StructuredMemoryTarget {
|
|
166
|
+
const normalized = (value || fallback).trim().toLowerCase();
|
|
167
|
+
return STRUCTURED_MEMORY_TARGETS.includes(normalized as StructuredMemoryTarget) ? (normalized as StructuredMemoryTarget) : fallback;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function readStructuredEntries(target: StructuredMemoryTarget): string[] {
|
|
171
|
+
const content = readFileSafe(structuredMemoryPath(target))?.trim();
|
|
172
|
+
if (!content) return [];
|
|
173
|
+
return content.split(ENTRY_DELIMITER).map((entry) => entry.trim()).filter(Boolean);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function writeStructuredEntries(target: StructuredMemoryTarget, entries: string[]): void {
|
|
177
|
+
ensureDirs();
|
|
178
|
+
const content = entries.map((entry) => entry.trim()).filter(Boolean).join(ENTRY_DELIMITER);
|
|
179
|
+
fs.writeFileSync(structuredMemoryPath(target), content, "utf-8");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function dedupeEntries(entries: string[]): string[] {
|
|
183
|
+
return [...new Set(entries.map((entry) => entry.trim()).filter(Boolean))];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function sanitizeMetadataValue(value: string | undefined): string | undefined {
|
|
187
|
+
const trimmed = value?.trim();
|
|
188
|
+
if (!trimmed) return undefined;
|
|
189
|
+
return trimmed.replace(/[\s\]]+/g, "-");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildStructuredEntry(options: StructuredWriteOptions): string {
|
|
193
|
+
const content = options.content.trim();
|
|
194
|
+
if (!content) throw new Error("content is required");
|
|
195
|
+
const firstLine = content.split("\n")[0] || "";
|
|
196
|
+
const hasExplicitMetadata = parseStructuredMetadata(firstLine) !== undefined;
|
|
197
|
+
const hasMetadataArgs = Boolean(
|
|
198
|
+
options.type || options.status || options.date || options.reset || options.month || options.provider || options.used || options.limit || options.ttlDays,
|
|
199
|
+
);
|
|
200
|
+
if (hasExplicitMetadata && !hasMetadataArgs) return content;
|
|
201
|
+
|
|
202
|
+
const type = sanitizeMetadataValue(options.type) || (options.target === "review" ? "review" : "fact");
|
|
203
|
+
if ((type === "event" || type === "temporary") && !options.date) throw new Error(`${type} memory requires date.`);
|
|
204
|
+
if (type === "quota" && !options.provider) throw new Error("quota memory requires provider.");
|
|
205
|
+
|
|
206
|
+
const metadata: Record<string, string> = { type };
|
|
207
|
+
const status = sanitizeMetadataValue(options.status) || (type === "event" ? "planned" : type === "temporary" || type === "quota" ? "active" : undefined);
|
|
208
|
+
if (status) metadata.status = status;
|
|
209
|
+
const fields = {
|
|
210
|
+
date: sanitizeMetadataValue(options.date),
|
|
211
|
+
reset: sanitizeMetadataValue(options.reset),
|
|
212
|
+
month: sanitizeMetadataValue(options.month),
|
|
213
|
+
provider: sanitizeMetadataValue(options.provider),
|
|
214
|
+
used: sanitizeMetadataValue(options.used),
|
|
215
|
+
limit: sanitizeMetadataValue(options.limit),
|
|
216
|
+
ttlDays: sanitizeMetadataValue(options.ttlDays),
|
|
217
|
+
};
|
|
218
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
219
|
+
if (value) metadata[key] = value;
|
|
220
|
+
}
|
|
221
|
+
return `${serializeMetadata(metadata)}\n${content}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function addStructuredEntry(target: StructuredMemoryTarget, entry: string): boolean {
|
|
225
|
+
const entries = readStructuredEntries(target);
|
|
226
|
+
const normalized = entry.trim();
|
|
227
|
+
if (entries.includes(normalized)) return false;
|
|
228
|
+
writeStructuredEntries(target, [...entries, normalized]);
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function replaceStructuredEntry(target: StructuredMemoryTarget, oldText: string, content: string): string {
|
|
233
|
+
if (!oldText.trim()) throw new Error("oldText is required");
|
|
234
|
+
if (!content.trim()) throw new Error("content is required");
|
|
235
|
+
const entries = readStructuredEntries(target);
|
|
236
|
+
const matches = entries.map((entry, index) => ({ entry, index })).filter(({ entry }) => entry.includes(oldText));
|
|
237
|
+
if (matches.length === 0) throw new Error(`No ${target} entry matched '${oldText}'.`);
|
|
238
|
+
if (new Set(matches.map(({ entry }) => entry)).size > 1) throw new Error(`Multiple ${target} entries matched '${oldText}'. Use a more specific oldText.`);
|
|
239
|
+
entries[matches[0].index] = content.trim();
|
|
240
|
+
writeStructuredEntries(target, dedupeEntries(entries));
|
|
241
|
+
return `Replaced ${target} entry.`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function removeStructuredEntry(target: StructuredMemoryTarget, oldText: string): string {
|
|
245
|
+
if (!oldText.trim()) throw new Error("oldText is required");
|
|
246
|
+
const entries = readStructuredEntries(target);
|
|
247
|
+
const matches = entries.map((entry, index) => ({ entry, index })).filter(({ entry }) => entry.includes(oldText));
|
|
248
|
+
if (matches.length === 0) throw new Error(`No ${target} entry matched '${oldText}'.`);
|
|
249
|
+
if (new Set(matches.map(({ entry }) => entry)).size > 1) throw new Error(`Multiple ${target} entries matched '${oldText}'. Use a more specific oldText.`);
|
|
250
|
+
entries.splice(matches[0].index, 1);
|
|
251
|
+
writeStructuredEntries(target, entries);
|
|
252
|
+
return `Removed ${target} entry.`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function replaceAllStructuredEntries(target: StructuredMemoryTarget, content: string): string {
|
|
256
|
+
const entries = content.split(ENTRY_DELIMITER).map((entry) => entry.trim()).filter(Boolean);
|
|
257
|
+
writeStructuredEntries(target, dedupeEntries(entries));
|
|
258
|
+
return `Replaced all ${target} entries (${entries.length}).`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function compactStructuredEntries(target: StructuredMemoryTarget): string {
|
|
262
|
+
const before = readStructuredEntries(target);
|
|
263
|
+
const after = dedupeEntries(before);
|
|
264
|
+
writeStructuredEntries(target, after);
|
|
265
|
+
return `Compacted ${target}: ${before.length} -> ${after.length} entries.`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function formatStructuredEntries(target: StructuredMemoryTarget): string {
|
|
269
|
+
const entries = readStructuredEntries(target);
|
|
270
|
+
if (entries.length === 0) return `${target}: no entries`;
|
|
271
|
+
return `# ${target}\n\n${entries.map((entry, index) => `${index + 1}. ${entry}`).join("\n\n")}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isCurrentStructuredState(entry: string): boolean {
|
|
275
|
+
const status = parseStructuredEntry(entry).metadata.status;
|
|
276
|
+
return status !== "past" && status !== "archived";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Limits + preview helpers
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
const RESPONSE_PREVIEW_MAX_CHARS = 4_000;
|
|
284
|
+
const RESPONSE_PREVIEW_MAX_LINES = 120;
|
|
285
|
+
|
|
286
|
+
const CONTEXT_LONG_TERM_MAX_CHARS = 4_000;
|
|
287
|
+
const CONTEXT_LONG_TERM_MAX_LINES = 150;
|
|
288
|
+
const CONTEXT_SCRATCHPAD_MAX_CHARS = 2_000;
|
|
289
|
+
const CONTEXT_SCRATCHPAD_MAX_LINES = 120;
|
|
290
|
+
const CONTEXT_DAILY_MAX_CHARS = 3_000;
|
|
291
|
+
const CONTEXT_DAILY_MAX_LINES = 120;
|
|
292
|
+
const CONTEXT_SEARCH_MAX_CHARS = 2_500;
|
|
293
|
+
const CONTEXT_SEARCH_MAX_LINES = 80;
|
|
294
|
+
const CONTEXT_MAX_CHARS = 16_000;
|
|
295
|
+
|
|
296
|
+
const EXIT_SUMMARY_MAX_CHARS = 80_000;
|
|
297
|
+
const EXIT_SUMMARY_SYSTEM_PROMPT = [
|
|
298
|
+
"You are a session recap assistant.",
|
|
299
|
+
"Read the conversation and extract key decisions, lessons learned, notes, and follow-ups.",
|
|
300
|
+
"Return ONLY markdown in the specified format, without any extra commentary.",
|
|
301
|
+
].join("\n");
|
|
302
|
+
|
|
303
|
+
const LEARNING_EXTRACTOR_MAX_CHARS = 60_000;
|
|
304
|
+
const LEARNING_EXTRACTOR_SYSTEM_PROMPT = [
|
|
305
|
+
"You extract conservative memory-learning review candidates.",
|
|
306
|
+
"Return ONLY JSON. Do not include markdown fences or commentary.",
|
|
307
|
+
"Write candidates for durable preferences, project facts, verified bug fixes, or reusable skill-worthy methods only.",
|
|
308
|
+
"Do not include workflow or loop artifacts.",
|
|
309
|
+
].join("\n");
|
|
310
|
+
|
|
311
|
+
const ENTRY_DELIMITER = "\n§\n";
|
|
312
|
+
const STRUCTURED_MEMORY_TARGETS = ["memory", "user", "state", "review"] as const;
|
|
313
|
+
const MEMORY_WRITE_TARGETS = ["long_term", "daily", "state", "user", "review"] as const;
|
|
314
|
+
const MEMORY_READ_TARGETS = ["long_term", "scratchpad", "daily", "list", "user", "state", "review", "all"] as const;
|
|
315
|
+
const MEMORY_EDIT_ACTIONS = ["read", "add", "replace", "remove", "replace_all", "compact"] as const;
|
|
316
|
+
const STRUCTURED_MEMORY_TYPES = ["fact", "preference", "event", "temporary", "quota", "review"] as const;
|
|
317
|
+
|
|
318
|
+
type StructuredMemoryTarget = (typeof STRUCTURED_MEMORY_TARGETS)[number];
|
|
319
|
+
type MemoryWriteTarget = (typeof MEMORY_WRITE_TARGETS)[number];
|
|
320
|
+
type MemoryReadTarget = (typeof MEMORY_READ_TARGETS)[number];
|
|
321
|
+
type MemoryEditAction = (typeof MEMORY_EDIT_ACTIONS)[number];
|
|
322
|
+
|
|
323
|
+
type StructuredWriteOptions = {
|
|
324
|
+
target: StructuredMemoryTarget;
|
|
325
|
+
content: string;
|
|
326
|
+
type?: string;
|
|
327
|
+
status?: string;
|
|
328
|
+
date?: string;
|
|
329
|
+
reset?: string;
|
|
330
|
+
month?: string;
|
|
331
|
+
provider?: string;
|
|
332
|
+
used?: string;
|
|
333
|
+
limit?: string;
|
|
334
|
+
ttlDays?: string;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
type TruncateMode = "start" | "end" | "middle";
|
|
338
|
+
|
|
339
|
+
interface PreviewResult {
|
|
340
|
+
preview: string;
|
|
341
|
+
truncated: boolean;
|
|
342
|
+
totalLines: number;
|
|
343
|
+
totalChars: number;
|
|
344
|
+
previewLines: number;
|
|
345
|
+
previewChars: number;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function normalizeContent(content: string): string {
|
|
349
|
+
return content.trim();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function truncateLines(lines: string[], maxLines: number, mode: TruncateMode) {
|
|
353
|
+
if (maxLines <= 0 || lines.length <= maxLines) {
|
|
354
|
+
return { lines, truncated: false };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (mode === "end") {
|
|
358
|
+
return { lines: lines.slice(-maxLines), truncated: true };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (mode === "middle" && maxLines > 1) {
|
|
362
|
+
const marker = "... (truncated) ...";
|
|
363
|
+
const keep = maxLines - 1;
|
|
364
|
+
const headCount = Math.ceil(keep / 2);
|
|
365
|
+
const tailCount = Math.floor(keep / 2);
|
|
366
|
+
const head = lines.slice(0, headCount);
|
|
367
|
+
const tail = tailCount > 0 ? lines.slice(-tailCount) : [];
|
|
368
|
+
return { lines: [...head, marker, ...tail], truncated: true };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return { lines: lines.slice(0, maxLines), truncated: true };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function truncateText(text: string, maxChars: number, mode: TruncateMode) {
|
|
375
|
+
if (maxChars <= 0 || text.length <= maxChars) {
|
|
376
|
+
return { text, truncated: false };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (mode === "end") {
|
|
380
|
+
return { text: text.slice(-maxChars), truncated: true };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (mode === "middle" && maxChars > 10) {
|
|
384
|
+
const marker = "... (truncated) ...";
|
|
385
|
+
const keep = maxChars - marker.length;
|
|
386
|
+
if (keep > 0) {
|
|
387
|
+
const headCount = Math.ceil(keep / 2);
|
|
388
|
+
const tailCount = Math.floor(keep / 2);
|
|
389
|
+
return {
|
|
390
|
+
text: text.slice(0, headCount) + marker + text.slice(text.length - tailCount),
|
|
391
|
+
truncated: true,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return { text: text.slice(0, maxChars), truncated: true };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function buildPreview(
|
|
400
|
+
content: string,
|
|
401
|
+
options: { maxLines: number; maxChars: number; mode: TruncateMode },
|
|
402
|
+
): PreviewResult {
|
|
403
|
+
const normalized = normalizeContent(content);
|
|
404
|
+
if (!normalized) {
|
|
405
|
+
return {
|
|
406
|
+
preview: "",
|
|
407
|
+
truncated: false,
|
|
408
|
+
totalLines: 0,
|
|
409
|
+
totalChars: 0,
|
|
410
|
+
previewLines: 0,
|
|
411
|
+
previewChars: 0,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const lines = normalized.split("\n");
|
|
416
|
+
const totalLines = lines.length;
|
|
417
|
+
const totalChars = normalized.length;
|
|
418
|
+
|
|
419
|
+
const lineResult = truncateLines(lines, options.maxLines, options.mode);
|
|
420
|
+
const text = lineResult.lines.join("\n");
|
|
421
|
+
const charResult = truncateText(text, options.maxChars, options.mode);
|
|
422
|
+
const preview = charResult.text;
|
|
423
|
+
|
|
424
|
+
const previewLines = preview ? preview.split("\n").length : 0;
|
|
425
|
+
const previewChars = preview.length;
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
preview,
|
|
429
|
+
truncated: lineResult.truncated || charResult.truncated,
|
|
430
|
+
totalLines,
|
|
431
|
+
totalChars,
|
|
432
|
+
previewLines,
|
|
433
|
+
previewChars,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function formatPreviewBlock(label: string, content: string, mode: TruncateMode) {
|
|
438
|
+
const result = buildPreview(content, {
|
|
439
|
+
maxLines: RESPONSE_PREVIEW_MAX_LINES,
|
|
440
|
+
maxChars: RESPONSE_PREVIEW_MAX_CHARS,
|
|
441
|
+
mode,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
if (!result.preview) {
|
|
445
|
+
return `${label}: empty.`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const meta = `${label} (${result.totalLines} lines, ${result.totalChars} chars)`;
|
|
449
|
+
const note = result.truncated
|
|
450
|
+
? `\n[preview truncated: showing ${result.previewLines}/${result.totalLines} lines, ${result.previewChars}/${result.totalChars} chars]`
|
|
451
|
+
: "";
|
|
452
|
+
return `${meta}\n\n${result.preview}${note}`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function formatContextSection(label: string, content: string, mode: TruncateMode, maxLines: number, maxChars: number) {
|
|
456
|
+
const result = buildPreview(content, { maxLines, maxChars, mode });
|
|
457
|
+
if (!result.preview) {
|
|
458
|
+
return "";
|
|
459
|
+
}
|
|
460
|
+
const note = result.truncated
|
|
461
|
+
? `\n\n[truncated: showing ${result.previewLines}/${result.totalLines} lines, ${result.previewChars}/${result.totalChars} chars]`
|
|
462
|
+
: "";
|
|
463
|
+
return `${label}\n\n${result.preview}${note}`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
type ExitSummaryReason = "ctrl+d" | "slash-quit" | "session-end";
|
|
467
|
+
type TransitionHandoffReason = "new" | "fork";
|
|
468
|
+
|
|
469
|
+
interface ExitSummaryResult {
|
|
470
|
+
summary: string | null;
|
|
471
|
+
error?: string;
|
|
472
|
+
hasMessages: boolean;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function formatExitSummaryReason(reason: ExitSummaryReason): string {
|
|
476
|
+
if (reason === "ctrl+d") return "ctrl+d";
|
|
477
|
+
if (reason === "slash-quit") return "/quit";
|
|
478
|
+
return "session-end";
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function truncateConversationForSummary(conversationText: string): {
|
|
482
|
+
text: string;
|
|
483
|
+
truncated: boolean;
|
|
484
|
+
totalChars: number;
|
|
485
|
+
} {
|
|
486
|
+
const trimmed = conversationText.trim();
|
|
487
|
+
if (!trimmed) {
|
|
488
|
+
return { text: "", truncated: false, totalChars: 0 };
|
|
489
|
+
}
|
|
490
|
+
const truncated = truncateText(trimmed, EXIT_SUMMARY_MAX_CHARS, "end");
|
|
491
|
+
return {
|
|
492
|
+
text: truncated.text,
|
|
493
|
+
truncated: truncated.truncated,
|
|
494
|
+
totalChars: trimmed.length,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function buildExitSummaryPrompt(conversationText: string, truncated: boolean, totalChars: number): string {
|
|
499
|
+
const lines = [
|
|
500
|
+
"Review the conversation and extract important decisions, lessons learned, notes, and follow-ups for a daily log.",
|
|
501
|
+
"Return markdown only with these exact headings:",
|
|
502
|
+
"### Decisions",
|
|
503
|
+
"### Lessons Learned",
|
|
504
|
+
"### Notes",
|
|
505
|
+
"### Follow-ups",
|
|
506
|
+
'Use bullet points under each heading. If there is nothing, write "None.".',
|
|
507
|
+
];
|
|
508
|
+
|
|
509
|
+
if (truncated) {
|
|
510
|
+
lines.push(
|
|
511
|
+
`Note: Conversation transcript was truncated to the most recent ${conversationText.length} of ${totalChars} characters.`,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
lines.push("", "<conversation>", conversationText, "</conversation>");
|
|
516
|
+
return lines.join("\n");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export function getMemoryLearningMode(env: MemoryEnv = process.env): "off" | "review" | "auto-review" {
|
|
520
|
+
const value = (env.PI_MEMORY_LEARNING || "review").toLowerCase();
|
|
521
|
+
return value === "off" || value === "auto-review" ? value : "review";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function getMemorySkillDraftsMode(env: MemoryEnv = process.env): "off" | "review" {
|
|
525
|
+
return env.PI_MEMORY_SKILL_DRAFTS?.toLowerCase() === "off" ? "off" : "review";
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function getMemoryLearningMinConfidence(env: MemoryEnv = process.env): "low" | "medium" | "high" {
|
|
529
|
+
const value = (env.PI_MEMORY_LEARNING_MIN_CONFIDENCE || "medium").toLowerCase();
|
|
530
|
+
return value === "low" || value === "high" ? value : "medium";
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function getMemoryAutoApproveMemory(env: MemoryEnv = process.env): boolean {
|
|
534
|
+
return ["1", "true", "yes", "on"].includes((env.PI_MEMORY_AUTO_APPROVE_MEMORY || "").toLowerCase());
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function getMemoryAutoApproveSkillDrafts(env: MemoryEnv = process.env): boolean {
|
|
538
|
+
return ["1", "true", "yes", "on"].includes((env.PI_MEMORY_AUTO_APPROVE_SKILL_DRAFTS || "").toLowerCase());
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function confidenceRank(confidence: "low" | "medium" | "high"): number {
|
|
542
|
+
return confidence === "low" ? 0 : confidence === "medium" ? 1 : 2;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function shouldKeepLearningCandidate(confidence: "low" | "medium" | "high", env: MemoryEnv = process.env): boolean {
|
|
546
|
+
return confidenceRank(confidence) >= confidenceRank(getMemoryLearningMinConfidence(env));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function buildLearningExtractorPrompt(conversationText: string, truncated: boolean, totalChars: number): string {
|
|
550
|
+
const lines = [
|
|
551
|
+
"Extract zero or more review candidates from this session transcript.",
|
|
552
|
+
"Return JSON exactly shaped as: {\"candidates\":[{\"kind\":\"bug_fix|skill_candidate|preference|project_fact\",\"confidence\":\"low|medium|high\",\"signature\":\"short stable signature\",\"summary\":\"optional concise summary\",\"targetHints\":[\"memory\",\"skill\"],\"evidence\":\"optional compact evidence\"}]}",
|
|
553
|
+
"Only include verified bug fixes when a failure was followed by an edit/action and successful validation.",
|
|
554
|
+
"Drop one-off trivia, transient status, workflow artifacts, and loop artifacts.",
|
|
555
|
+
];
|
|
556
|
+
if (truncated) lines.push(`Transcript was truncated to the most recent ${conversationText.length} of ${totalChars} characters.`);
|
|
557
|
+
lines.push("", "<conversation>", conversationText, "</conversation>");
|
|
558
|
+
return lines.join("\n");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function buildExitSummaryFallback(error?: string): string {
|
|
562
|
+
const note = error ? `- Auto-summary unavailable: ${error}.` : "- Auto-summary unavailable.";
|
|
563
|
+
return [
|
|
564
|
+
"### Decisions",
|
|
565
|
+
"- None.",
|
|
566
|
+
"### Lessons Learned",
|
|
567
|
+
"- None.",
|
|
568
|
+
"### Notes",
|
|
569
|
+
note,
|
|
570
|
+
"### Follow-ups",
|
|
571
|
+
"- None.",
|
|
572
|
+
].join("\n");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function formatExitSummaryEntry(
|
|
576
|
+
summary: string,
|
|
577
|
+
reason: ExitSummaryReason,
|
|
578
|
+
sessionId: string,
|
|
579
|
+
timestamp: string,
|
|
580
|
+
): string {
|
|
581
|
+
const header = `## Session Summary (auto, exit: ${formatExitSummaryReason(reason)})`;
|
|
582
|
+
return [`<!-- ${timestamp} [${sessionId}] -->`, header, "", summary.trim()].join("\n");
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function getSessionBranch(ctx: ExtensionContext): SessionEntry[] | null {
|
|
586
|
+
const sessionManager = ctx.sessionManager as ExtensionContext["sessionManager"] & {
|
|
587
|
+
getBranch?: () => SessionEntry[];
|
|
588
|
+
};
|
|
589
|
+
if (typeof sessionManager?.getBranch !== "function") {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
return sessionManager.getBranch();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function resolveExitSummaryApiKey(ctx: ExtensionContext): Promise<string | undefined> {
|
|
596
|
+
if (!ctx.model) return undefined;
|
|
597
|
+
|
|
598
|
+
const modelRegistry = ctx.modelRegistry as ExtensionContext["modelRegistry"] & {
|
|
599
|
+
getApiKey?: (model: NonNullable<ExtensionContext["model"]>) => Promise<string | undefined>;
|
|
600
|
+
getApiKeyForProvider?: (provider: string) => Promise<string | undefined>;
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
if (typeof modelRegistry?.getApiKey === "function") {
|
|
604
|
+
return modelRegistry.getApiKey(ctx.model);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (typeof modelRegistry?.getApiKeyForProvider === "function") {
|
|
608
|
+
return modelRegistry.getApiKeyForProvider(ctx.model.provider);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return undefined;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function serializeSessionConversation(branch: SessionEntry[]): { text: string; hasMessages: boolean } {
|
|
615
|
+
const messages = branch
|
|
616
|
+
.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
|
|
617
|
+
.map((entry) => entry.message);
|
|
618
|
+
if (messages.length === 0) return { text: "", hasMessages: false };
|
|
619
|
+
return { text: serializeConversation(convertToLlm(messages)), hasMessages: true };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export function parseLearningExtractorResponse(raw: string): ReviewCandidateInput[] {
|
|
623
|
+
let parsed: unknown;
|
|
624
|
+
try {
|
|
625
|
+
parsed = JSON.parse(raw.trim());
|
|
626
|
+
} catch {
|
|
627
|
+
return [];
|
|
628
|
+
}
|
|
629
|
+
if (!parsed || typeof parsed !== "object" || !("candidates" in parsed) || !Array.isArray(parsed.candidates)) return [];
|
|
630
|
+
const candidates: ReviewCandidateInput[] = [];
|
|
631
|
+
for (const candidate of parsed.candidates) {
|
|
632
|
+
if (!candidate || typeof candidate !== "object") continue;
|
|
633
|
+
const record = candidate as Record<string, unknown>;
|
|
634
|
+
const kind = typeof record.kind === "string" && REVIEW_CANDIDATE_KINDS.includes(record.kind as ReviewCandidateInput["kind"])
|
|
635
|
+
? record.kind as ReviewCandidateInput["kind"]
|
|
636
|
+
: undefined;
|
|
637
|
+
const confidence = typeof record.confidence === "string" && REVIEW_CONFIDENCES.includes(record.confidence as ReviewCandidateInput["confidence"])
|
|
638
|
+
? record.confidence as ReviewCandidateInput["confidence"]
|
|
639
|
+
: undefined;
|
|
640
|
+
const signature = typeof record.signature === "string" ? record.signature.trim() : "";
|
|
641
|
+
if (!kind || !confidence || !signature || !shouldKeepLearningCandidate(confidence)) continue;
|
|
642
|
+
const targetHints = Array.isArray(record.targetHints)
|
|
643
|
+
? record.targetHints.filter((hint): hint is ReviewCandidateInput["targetHints"][number] => typeof hint === "string" && REVIEW_TARGET_HINTS.includes(hint as ReviewCandidateInput["targetHints"][number]))
|
|
644
|
+
: undefined;
|
|
645
|
+
candidates.push({
|
|
646
|
+
kind,
|
|
647
|
+
confidence,
|
|
648
|
+
signature,
|
|
649
|
+
summary: typeof record.summary === "string" ? record.summary.trim() : undefined,
|
|
650
|
+
targetHints,
|
|
651
|
+
evidence: typeof record.evidence === "string" ? record.evidence.trim() : undefined,
|
|
652
|
+
source: "session_shutdown",
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
return candidates;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function runSessionLearningExtractor(ctx: ExtensionContext): Promise<number> {
|
|
659
|
+
if (getMemoryLearningMode() === "off") return 0;
|
|
660
|
+
const branch = getSessionBranch(ctx);
|
|
661
|
+
if (!branch || !ctx.model) return 0;
|
|
662
|
+
const apiKey = await resolveExitSummaryApiKey(ctx);
|
|
663
|
+
if (!apiKey) return 0;
|
|
664
|
+
const conversation = serializeSessionConversation(branch);
|
|
665
|
+
if (!conversation.hasMessages || !conversation.text.trim()) return 0;
|
|
666
|
+
const truncated = truncateText(conversation.text.trim(), LEARNING_EXTRACTOR_MAX_CHARS, "end");
|
|
667
|
+
const messages: Message[] = [{
|
|
668
|
+
role: "user",
|
|
669
|
+
content: [{ type: "text", text: buildLearningExtractorPrompt(truncated.text, truncated.truncated, conversation.text.trim().length) }],
|
|
670
|
+
timestamp: Date.now(),
|
|
671
|
+
}];
|
|
672
|
+
try {
|
|
673
|
+
const response = await complete(ctx.model, { systemPrompt: LEARNING_EXTRACTOR_SYSTEM_PROMPT, messages }, { apiKey, reasoningEffort: "low" });
|
|
674
|
+
const raw = response.content.filter((part): part is { type: "text"; text: string } => part.type === "text").map((part) => part.text).join("\n");
|
|
675
|
+
const candidates = parseLearningExtractorResponse(raw);
|
|
676
|
+
let written = 0;
|
|
677
|
+
const store = new FileMemoryStore(MEMORY_DIR);
|
|
678
|
+
for (const candidate of candidates) {
|
|
679
|
+
const result = await upsertReviewCandidate(store, candidate);
|
|
680
|
+
if (result.changed) written += 1;
|
|
681
|
+
}
|
|
682
|
+
return written;
|
|
683
|
+
} catch {
|
|
684
|
+
return 0;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function generateExitSummary(ctx: ExtensionContext): Promise<ExitSummaryResult> {
|
|
689
|
+
const branch = getSessionBranch(ctx);
|
|
690
|
+
if (!branch) {
|
|
691
|
+
return { summary: null, error: "Session branch unavailable", hasMessages: false };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const messages = branch
|
|
695
|
+
.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
|
|
696
|
+
.map((entry) => entry.message);
|
|
697
|
+
|
|
698
|
+
if (messages.length === 0) {
|
|
699
|
+
return { summary: null, hasMessages: false };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (!ctx.model) {
|
|
703
|
+
return { summary: null, error: "No active model", hasMessages: true };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const apiKey = await resolveExitSummaryApiKey(ctx);
|
|
707
|
+
if (!apiKey) {
|
|
708
|
+
return {
|
|
709
|
+
summary: null,
|
|
710
|
+
error: `API key resolution unavailable for ${ctx.model.provider}/${ctx.model.id}`,
|
|
711
|
+
hasMessages: true,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const conversationText = serializeConversation(convertToLlm(messages));
|
|
716
|
+
const { text: truncatedText, truncated, totalChars } = truncateConversationForSummary(conversationText);
|
|
717
|
+
if (!truncatedText.trim()) {
|
|
718
|
+
return { summary: null, error: "No conversation text to summarize", hasMessages: true };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const summaryMessages: Message[] = [
|
|
722
|
+
{
|
|
723
|
+
role: "user",
|
|
724
|
+
content: [{ type: "text", text: buildExitSummaryPrompt(truncatedText, truncated, totalChars) }],
|
|
725
|
+
timestamp: Date.now(),
|
|
726
|
+
},
|
|
727
|
+
];
|
|
728
|
+
|
|
729
|
+
try {
|
|
730
|
+
const response = await complete(
|
|
731
|
+
ctx.model,
|
|
732
|
+
{ systemPrompt: EXIT_SUMMARY_SYSTEM_PROMPT, messages: summaryMessages },
|
|
733
|
+
{ apiKey, reasoningEffort: "low" },
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
const summaryText = response.content
|
|
737
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
738
|
+
.map((c) => c.text)
|
|
739
|
+
.join("\n")
|
|
740
|
+
.trim();
|
|
741
|
+
|
|
742
|
+
if (!summaryText) {
|
|
743
|
+
return { summary: null, error: "Summary was empty", hasMessages: true };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return { summary: summaryText, hasMessages: true };
|
|
747
|
+
} catch (err) {
|
|
748
|
+
return { summary: null, error: err instanceof Error ? err.message : String(err), hasMessages: true };
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function getQmdUpdateMode(): "background" | "manual" | "off" {
|
|
753
|
+
const mode = (process.env.PI_MEMORY_QMD_UPDATE ?? "background").toLowerCase();
|
|
754
|
+
if (mode === "manual" || mode === "off" || mode === "background") {
|
|
755
|
+
return mode;
|
|
756
|
+
}
|
|
757
|
+
return "background";
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
export function shouldSummarizeLifecycleTransitions(): boolean {
|
|
761
|
+
const value = (process.env.PI_MEMORY_SUMMARIZE_TRANSITIONS ?? "").toLowerCase();
|
|
762
|
+
return value === "1" || value === "true" || value === "yes" || value === "on";
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export function shouldSkipExitSummaryForReason(reason: string | undefined): boolean {
|
|
766
|
+
if (!reason) return false;
|
|
767
|
+
if (shouldSummarizeLifecycleTransitions()) return false;
|
|
768
|
+
return ["reload", "new", "resume", "fork"].includes(reason);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export function shouldWriteTransitionHandoffForReason(reason: string | undefined): reason is TransitionHandoffReason {
|
|
772
|
+
if (shouldSummarizeLifecycleTransitions()) return false;
|
|
773
|
+
return reason === "new" || reason === "fork";
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function formatTransitionHandoffReason(reason: TransitionHandoffReason): string {
|
|
777
|
+
return `/${reason}`;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function getMessageText(message: Message): string {
|
|
781
|
+
return message.content
|
|
782
|
+
.map((part) => (part.type === "text" ? part.text : ""))
|
|
783
|
+
.join("\n")
|
|
784
|
+
.trim();
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function previewMessageText(text: string): string {
|
|
788
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 240);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export function buildTransitionHandoff(ctx: ExtensionContext, reason: TransitionHandoffReason, sessionId: string, timestamp: string): string | null {
|
|
792
|
+
const parts: string[] = [];
|
|
793
|
+
const branch = getSessionBranch(ctx);
|
|
794
|
+
const recentMessages = (branch ?? [])
|
|
795
|
+
.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
|
|
796
|
+
.map((entry) => entry.message)
|
|
797
|
+
.map((message) => ({ role: message.role, text: previewMessageText(getMessageText(message)) }))
|
|
798
|
+
.filter((message) => message.text)
|
|
799
|
+
.slice(-6);
|
|
800
|
+
|
|
801
|
+
if (recentMessages.length > 0) {
|
|
802
|
+
parts.push("### Recent conversation", "");
|
|
803
|
+
for (const message of recentMessages) {
|
|
804
|
+
parts.push(`- **${message.role}:** ${message.text}`);
|
|
805
|
+
}
|
|
806
|
+
parts.push("");
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const scratchpad = readFileSafe(SCRATCHPAD_FILE);
|
|
810
|
+
if (scratchpad?.trim()) {
|
|
811
|
+
const openItems = parseScratchpad(scratchpad).filter((item) => !item.done);
|
|
812
|
+
if (openItems.length > 0) {
|
|
813
|
+
parts.push("### Open scratchpad items", "");
|
|
814
|
+
for (const item of openItems) {
|
|
815
|
+
parts.push(`- [ ] ${item.text}`);
|
|
816
|
+
}
|
|
817
|
+
parts.push("");
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (parts.length === 0) return null;
|
|
822
|
+
|
|
823
|
+
return [
|
|
824
|
+
`<!-- ${timestamp} [${sessionId}] -->`,
|
|
825
|
+
`## Session Handoff (auto, transition: ${formatTransitionHandoffReason(reason)})`,
|
|
826
|
+
"",
|
|
827
|
+
...parts,
|
|
828
|
+
].join("\n").trim();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function writeTransitionHandoff(ctx: ExtensionContext, reason: TransitionHandoffReason): Promise<boolean> {
|
|
832
|
+
ensureDirs();
|
|
833
|
+
const sid = shortSessionId(ctx.sessionManager.getSessionId());
|
|
834
|
+
const ts = nowTimestamp();
|
|
835
|
+
const handoff = buildTransitionHandoff(ctx, reason, sid, ts);
|
|
836
|
+
if (!handoff) return false;
|
|
837
|
+
const filePath = dailyPath(todayStr());
|
|
838
|
+
const existing = readFileSafe(filePath) ?? "";
|
|
839
|
+
const separator = existing.trim() ? "\n\n" : "";
|
|
840
|
+
fs.writeFileSync(filePath, existing + separator + handoff, "utf-8");
|
|
841
|
+
await ensureQmdAvailableForUpdate();
|
|
842
|
+
await runQmdUpdateNow();
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
async function ensureQmdAvailableForUpdate(): Promise<boolean> {
|
|
847
|
+
if (qmdAvailable) return true;
|
|
848
|
+
if (getQmdUpdateMode() !== "background") return false;
|
|
849
|
+
qmdAvailable = await detectQmd();
|
|
850
|
+
return qmdAvailable;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ---------------------------------------------------------------------------
|
|
854
|
+
// Scratchpad helpers
|
|
855
|
+
// ---------------------------------------------------------------------------
|
|
856
|
+
|
|
857
|
+
export interface ScratchpadItem {
|
|
858
|
+
done: boolean;
|
|
859
|
+
text: string;
|
|
860
|
+
meta: string; // the <!-- timestamp [session] --> comment
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
export function parseScratchpad(content: string): ScratchpadItem[] {
|
|
864
|
+
const items: ScratchpadItem[] = [];
|
|
865
|
+
const lines = content.split("\n");
|
|
866
|
+
for (let i = 0; i < lines.length; i++) {
|
|
867
|
+
const line = lines[i];
|
|
868
|
+
const match = line.match(/^- \[([ xX])\] (.+)$/);
|
|
869
|
+
if (match) {
|
|
870
|
+
let meta = "";
|
|
871
|
+
if (i > 0 && lines[i - 1].match(/^<!--.*-->$/)) {
|
|
872
|
+
meta = lines[i - 1];
|
|
873
|
+
}
|
|
874
|
+
items.push({
|
|
875
|
+
done: match[1].toLowerCase() === "x",
|
|
876
|
+
text: match[2],
|
|
877
|
+
meta,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return items;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
export function serializeScratchpad(items: ScratchpadItem[]): string {
|
|
885
|
+
const lines: string[] = ["# Scratchpad", ""];
|
|
886
|
+
for (const item of items) {
|
|
887
|
+
if (item.meta) {
|
|
888
|
+
lines.push(item.meta);
|
|
889
|
+
}
|
|
890
|
+
const checkbox = item.done ? "[x]" : "[ ]";
|
|
891
|
+
lines.push(`- ${checkbox} ${item.text}`);
|
|
892
|
+
}
|
|
893
|
+
return `${lines.join("\n")}\n`;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// ---------------------------------------------------------------------------
|
|
897
|
+
// Context builder
|
|
898
|
+
// ---------------------------------------------------------------------------
|
|
899
|
+
|
|
900
|
+
export function buildMemoryContext(searchResults?: string): string {
|
|
901
|
+
ensureDirs();
|
|
902
|
+
// Priority order: scratchpad > today's daily > search results > MEMORY.md > yesterday's daily
|
|
903
|
+
const sections: string[] = [];
|
|
904
|
+
|
|
905
|
+
const scratchpad = readFileSafe(SCRATCHPAD_FILE);
|
|
906
|
+
if (scratchpad?.trim()) {
|
|
907
|
+
const openItems = parseScratchpad(scratchpad).filter((i) => !i.done);
|
|
908
|
+
if (openItems.length > 0) {
|
|
909
|
+
const serialized = serializeScratchpad(openItems);
|
|
910
|
+
const section = formatContextSection(
|
|
911
|
+
"## SCRATCHPAD.md (working context)",
|
|
912
|
+
serialized,
|
|
913
|
+
"start",
|
|
914
|
+
CONTEXT_SCRATCHPAD_MAX_LINES,
|
|
915
|
+
CONTEXT_SCRATCHPAD_MAX_CHARS,
|
|
916
|
+
);
|
|
917
|
+
if (section) sections.push(section);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const today = todayStr();
|
|
922
|
+
const yesterday = yesterdayStr();
|
|
923
|
+
|
|
924
|
+
const todayContent = readFileSafe(dailyPath(today));
|
|
925
|
+
if (todayContent?.trim()) {
|
|
926
|
+
const section = formatContextSection(
|
|
927
|
+
`## Daily log: ${today} (today)`,
|
|
928
|
+
todayContent,
|
|
929
|
+
"end",
|
|
930
|
+
CONTEXT_DAILY_MAX_LINES,
|
|
931
|
+
CONTEXT_DAILY_MAX_CHARS,
|
|
932
|
+
);
|
|
933
|
+
if (section) sections.push(section);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (searchResults?.trim()) {
|
|
937
|
+
const section = formatContextSection(
|
|
938
|
+
"## Relevant memories (auto-retrieved)",
|
|
939
|
+
searchResults,
|
|
940
|
+
"start",
|
|
941
|
+
CONTEXT_SEARCH_MAX_LINES,
|
|
942
|
+
CONTEXT_SEARCH_MAX_CHARS,
|
|
943
|
+
);
|
|
944
|
+
if (section) sections.push(section);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const userProfile = readFileSafe(USER_FILE);
|
|
948
|
+
if (userProfile?.trim()) {
|
|
949
|
+
const section = formatContextSection(
|
|
950
|
+
"## USER.md (user profile)",
|
|
951
|
+
userProfile,
|
|
952
|
+
"middle",
|
|
953
|
+
CONTEXT_LONG_TERM_MAX_LINES,
|
|
954
|
+
CONTEXT_LONG_TERM_MAX_CHARS,
|
|
955
|
+
);
|
|
956
|
+
if (section) sections.push(section);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const stateEntries = readStructuredEntries("state").filter(isCurrentStructuredState);
|
|
960
|
+
if (stateEntries.length > 0) {
|
|
961
|
+
const section = formatContextSection(
|
|
962
|
+
"## STATE.md (current state)",
|
|
963
|
+
stateEntries.join(ENTRY_DELIMITER),
|
|
964
|
+
"middle",
|
|
965
|
+
CONTEXT_LONG_TERM_MAX_LINES,
|
|
966
|
+
CONTEXT_LONG_TERM_MAX_CHARS,
|
|
967
|
+
);
|
|
968
|
+
if (section) sections.push(section);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const longTerm = readFileSafe(MEMORY_FILE);
|
|
972
|
+
if (longTerm?.trim()) {
|
|
973
|
+
const section = formatContextSection(
|
|
974
|
+
"## MEMORY.md (long-term)",
|
|
975
|
+
longTerm,
|
|
976
|
+
"middle",
|
|
977
|
+
CONTEXT_LONG_TERM_MAX_LINES,
|
|
978
|
+
CONTEXT_LONG_TERM_MAX_CHARS,
|
|
979
|
+
);
|
|
980
|
+
if (section) sections.push(section);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const yesterdayContent = readFileSafe(dailyPath(yesterday));
|
|
984
|
+
if (yesterdayContent?.trim()) {
|
|
985
|
+
const section = formatContextSection(
|
|
986
|
+
`## Daily log: ${yesterday} (yesterday)`,
|
|
987
|
+
yesterdayContent,
|
|
988
|
+
"end",
|
|
989
|
+
CONTEXT_DAILY_MAX_LINES,
|
|
990
|
+
CONTEXT_DAILY_MAX_CHARS,
|
|
991
|
+
);
|
|
992
|
+
if (section) sections.push(section);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (sections.length === 0) {
|
|
996
|
+
return "";
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const context = `# Memory\n\n${sections.join("\n\n---\n\n")}`;
|
|
1000
|
+
if (context.length > CONTEXT_MAX_CHARS) {
|
|
1001
|
+
const result = buildPreview(context, {
|
|
1002
|
+
maxLines: Number.POSITIVE_INFINITY,
|
|
1003
|
+
maxChars: CONTEXT_MAX_CHARS,
|
|
1004
|
+
mode: "start",
|
|
1005
|
+
});
|
|
1006
|
+
const note = result.truncated
|
|
1007
|
+
? `\n\n[truncated overall context: showing ${result.previewChars}/${result.totalChars} chars]`
|
|
1008
|
+
: "";
|
|
1009
|
+
return `${result.preview}${note}`;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
return context;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// ---------------------------------------------------------------------------
|
|
1016
|
+
// QMD integration
|
|
1017
|
+
// ---------------------------------------------------------------------------
|
|
1018
|
+
|
|
1019
|
+
type ExecFileFn = typeof execFile;
|
|
1020
|
+
|
|
1021
|
+
function isQmdCommand(file: string | URL): boolean {
|
|
1022
|
+
if (typeof file !== "string") return false;
|
|
1023
|
+
const basename = file.replace(/\\/g, "/").split("/").pop()?.toLowerCase();
|
|
1024
|
+
return basename === "qmd" || basename === "qmd.cmd" || basename === "qmd.exe";
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const QMD_JS_REL = path.join("node_modules", "@tobilu", "qmd", "dist", "cli", "qmd.js");
|
|
1028
|
+
|
|
1029
|
+
let cachedQmdJsPath: string | null | undefined;
|
|
1030
|
+
|
|
1031
|
+
// On Windows, cmd-shim writes the literal `/bin/sh` (the package's shebang
|
|
1032
|
+
// interpreter) into both qmd.cmd and qmd.ps1, so both shims fail with
|
|
1033
|
+
// "system cannot find the path specified" / "'/bin/sh.exe' is not recognized"
|
|
1034
|
+
// outside cygwin/git-bash trees. Bypass the shims by locating qmd's JS entry
|
|
1035
|
+
// in a sibling node_modules directory of a PATH entry and invoking it with
|
|
1036
|
+
// node directly — the same thing the sh script in bin/qmd does when launched
|
|
1037
|
+
// via npm.
|
|
1038
|
+
export function resolveQmdJsPath(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
1039
|
+
if (cachedQmdJsPath !== undefined) return cachedQmdJsPath;
|
|
1040
|
+
const pathStr = env.PATH ?? env.Path ?? "";
|
|
1041
|
+
const entries = pathStr.split(path.delimiter).filter(Boolean);
|
|
1042
|
+
for (const dir of entries) {
|
|
1043
|
+
try {
|
|
1044
|
+
const candidate = path.join(dir, QMD_JS_REL);
|
|
1045
|
+
if (fs.statSync(candidate).isFile()) {
|
|
1046
|
+
cachedQmdJsPath = candidate;
|
|
1047
|
+
return candidate;
|
|
1048
|
+
}
|
|
1049
|
+
} catch {
|
|
1050
|
+
// keep scanning
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
cachedQmdJsPath = null;
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** Clear the resolved qmd.js cache (for testing). */
|
|
1058
|
+
export function _resetQmdJsResolutionForTest() {
|
|
1059
|
+
cachedQmdJsPath = undefined;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
export function buildQmdSpawn(
|
|
1063
|
+
file: string,
|
|
1064
|
+
args: readonly string[],
|
|
1065
|
+
platform: NodeJS.Platform = process.platform,
|
|
1066
|
+
qmdJsPath: string | null = null,
|
|
1067
|
+
): { file: string; args: string[] } {
|
|
1068
|
+
if (platform !== "win32" || !isQmdCommand(file) || !qmdJsPath) {
|
|
1069
|
+
return { file, args: [...args] };
|
|
1070
|
+
}
|
|
1071
|
+
return { file: "node", args: [qmdJsPath, ...args] };
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const execFileWithQmdOptions: ExecFileFn = ((
|
|
1075
|
+
file: string,
|
|
1076
|
+
args: readonly string[],
|
|
1077
|
+
options: ExecFileOptions,
|
|
1078
|
+
callback: (...args: any[]) => void,
|
|
1079
|
+
) => {
|
|
1080
|
+
const qmdJs = process.platform === "win32" && isQmdCommand(file) ? resolveQmdJsPath() : null;
|
|
1081
|
+
const spawn = buildQmdSpawn(file, args ?? [], process.platform, qmdJs);
|
|
1082
|
+
return execFile(spawn.file, spawn.args, options, callback as any);
|
|
1083
|
+
}) as ExecFileFn;
|
|
1084
|
+
|
|
1085
|
+
let execFileFn: ExecFileFn = execFileWithQmdOptions;
|
|
1086
|
+
|
|
1087
|
+
let qmdAvailable = false;
|
|
1088
|
+
let qmdAvailabilityCheckedAt = 0;
|
|
1089
|
+
// Positive results are stable for the session; negative results should refresh
|
|
1090
|
+
// quickly so users who install qmd (or run setupQmdCollection) mid-session
|
|
1091
|
+
// don't have to wait through a long TTL before retries succeed.
|
|
1092
|
+
const QMD_STATUS_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
1093
|
+
const QMD_STATUS_NEGATIVE_CACHE_TTL_MS = 5 * 1000;
|
|
1094
|
+
const qmdCollectionStatusCache = new Map<string, { checkedAt: number; exists: boolean }>();
|
|
1095
|
+
|
|
1096
|
+
function qmdStatusTtl(positive: boolean): number {
|
|
1097
|
+
return positive ? QMD_STATUS_CACHE_TTL_MS : QMD_STATUS_NEGATIVE_CACHE_TTL_MS;
|
|
1098
|
+
}
|
|
1099
|
+
let updateTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1100
|
+
let exitSummaryReason: ExitSummaryReason | null = null;
|
|
1101
|
+
let terminalInputUnsubscribe: (() => void) | null = null;
|
|
1102
|
+
|
|
1103
|
+
/** Override execFile implementation (for testing). */
|
|
1104
|
+
export function _setExecFileForTest(fn: ExecFileFn) {
|
|
1105
|
+
execFileFn = fn;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** Reset execFile implementation (for testing). */
|
|
1109
|
+
export function _resetExecFileForTest() {
|
|
1110
|
+
execFileFn = execFileWithQmdOptions;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** Set qmd availability flag (for testing). */
|
|
1114
|
+
export function _setQmdAvailable(value: boolean) {
|
|
1115
|
+
qmdAvailable = value;
|
|
1116
|
+
qmdAvailabilityCheckedAt = Date.now();
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** Get current qmd availability flag (for testing). */
|
|
1120
|
+
export function _getQmdAvailable(): boolean {
|
|
1121
|
+
return qmdAvailable;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** Get current update timer (for testing). */
|
|
1125
|
+
export function _getUpdateTimer(): ReturnType<typeof setTimeout> | null {
|
|
1126
|
+
return updateTimer;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** Clear the update timer (for testing). */
|
|
1130
|
+
export function _clearUpdateTimer() {
|
|
1131
|
+
if (updateTimer) {
|
|
1132
|
+
clearTimeout(updateTimer);
|
|
1133
|
+
updateTimer = null;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Clear qmd status caches (for testing). */
|
|
1138
|
+
export function _clearQmdStatusCaches() {
|
|
1139
|
+
qmdAvailabilityCheckedAt = 0;
|
|
1140
|
+
qmdCollectionStatusCache.clear();
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const QMD_REPO_URL = "https://github.com/tobi/qmd";
|
|
1144
|
+
|
|
1145
|
+
export function qmdInstallInstructions(): string {
|
|
1146
|
+
return [
|
|
1147
|
+
"memory_search requires qmd.",
|
|
1148
|
+
"",
|
|
1149
|
+
"Install qmd (requires Bun):",
|
|
1150
|
+
` bun install -g ${QMD_REPO_URL}`,
|
|
1151
|
+
" # ensure ~/.bun/bin is in your PATH",
|
|
1152
|
+
"",
|
|
1153
|
+
"Then set up the collection (one-time):",
|
|
1154
|
+
` qmd collection add ${MEMORY_DIR} --name pi-memory`,
|
|
1155
|
+
" qmd embed",
|
|
1156
|
+
].join("\n");
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
export function qmdCollectionInstructions(): string {
|
|
1160
|
+
return [
|
|
1161
|
+
"qmd collection pi-memory is not configured.",
|
|
1162
|
+
"",
|
|
1163
|
+
"Set up the collection (one-time):",
|
|
1164
|
+
` qmd collection add ${MEMORY_DIR} --name pi-memory`,
|
|
1165
|
+
" qmd embed",
|
|
1166
|
+
].join("\n");
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** Auto-create the pi-memory collection and path contexts in qmd. */
|
|
1170
|
+
export async function setupQmdCollection(): Promise<boolean> {
|
|
1171
|
+
try {
|
|
1172
|
+
await new Promise<void>((resolve, reject) => {
|
|
1173
|
+
execFileFn("qmd", ["collection", "add", MEMORY_DIR, "--name", "pi-memory"], { timeout: 10_000 }, (err) =>
|
|
1174
|
+
err ? reject(err) : resolve(),
|
|
1175
|
+
);
|
|
1176
|
+
});
|
|
1177
|
+
} catch {
|
|
1178
|
+
// Collection may already exist under a different name — not critical
|
|
1179
|
+
return false;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Add path contexts (best-effort, ignore errors)
|
|
1183
|
+
const contexts: [string, string][] = [
|
|
1184
|
+
["/daily", "Daily append-only work logs organized by date"],
|
|
1185
|
+
["/STATE.md", "Current dated state, events, temporary facts, and quotas"],
|
|
1186
|
+
["/USER.md", "Structured user profile and preferences"],
|
|
1187
|
+
["/REVIEW.md", "Memory review queue for stale or merge-candidate entries"],
|
|
1188
|
+
["/", "Curated long-term memory: decisions, preferences, facts, lessons"],
|
|
1189
|
+
];
|
|
1190
|
+
for (const [ctxPath, desc] of contexts) {
|
|
1191
|
+
try {
|
|
1192
|
+
await new Promise<void>((resolve, reject) => {
|
|
1193
|
+
execFileFn("qmd", ["context", "add", ctxPath, desc, "-c", "pi-memory"], { timeout: 10_000 }, (err) =>
|
|
1194
|
+
err ? reject(err) : resolve(),
|
|
1195
|
+
);
|
|
1196
|
+
});
|
|
1197
|
+
} catch {
|
|
1198
|
+
// Ignore — context may already exist
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
// Seed the cache so checkCollection("pi-memory") doesn't redundantly re-run
|
|
1202
|
+
// setupQmdCollection during the short negative-cache window.
|
|
1203
|
+
qmdCollectionStatusCache.set("pi-memory", { checkedAt: Date.now(), exists: true });
|
|
1204
|
+
return true;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
export function detectQmd(): Promise<boolean> {
|
|
1208
|
+
const now = Date.now();
|
|
1209
|
+
if (qmdAvailabilityCheckedAt && now - qmdAvailabilityCheckedAt < qmdStatusTtl(qmdAvailable)) {
|
|
1210
|
+
return Promise.resolve(qmdAvailable);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
return new Promise((resolve) => {
|
|
1214
|
+
// `qmd status` can trigger slow model/device probing on some systems (e.g. Vulkan fallback),
|
|
1215
|
+
// which may exceed short startup timeouts and produce false negatives.
|
|
1216
|
+
// `qmd collection list` is much lighter and still validates the binary is callable.
|
|
1217
|
+
execFileFn("qmd", ["collection", "list"], { timeout: 15_000 }, (err) => {
|
|
1218
|
+
qmdAvailable = !err;
|
|
1219
|
+
qmdAvailabilityCheckedAt = Date.now();
|
|
1220
|
+
resolve(qmdAvailable);
|
|
1221
|
+
});
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
export function checkCollection(name: string): Promise<boolean> {
|
|
1226
|
+
const cached = qmdCollectionStatusCache.get(name);
|
|
1227
|
+
const now = Date.now();
|
|
1228
|
+
if (cached && now - cached.checkedAt < qmdStatusTtl(cached.exists)) {
|
|
1229
|
+
return Promise.resolve(cached.exists);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
return new Promise((resolve) => {
|
|
1233
|
+
execFileFn("qmd", ["collection", "list", "--json"], { timeout: 10_000 }, (err, stdout) => {
|
|
1234
|
+
let exists = false;
|
|
1235
|
+
if (!err) {
|
|
1236
|
+
try {
|
|
1237
|
+
const collections = JSON.parse(stdout);
|
|
1238
|
+
if (Array.isArray(collections)) {
|
|
1239
|
+
exists = collections.some((entry) => {
|
|
1240
|
+
if (typeof entry === "string") return entry === name;
|
|
1241
|
+
if (entry && typeof entry === "object" && "name" in entry) {
|
|
1242
|
+
return (entry as { name?: string }).name === name;
|
|
1243
|
+
}
|
|
1244
|
+
return false;
|
|
1245
|
+
});
|
|
1246
|
+
} else {
|
|
1247
|
+
// qmd may output an object with a collections array or similar
|
|
1248
|
+
exists = stdout.includes(name);
|
|
1249
|
+
}
|
|
1250
|
+
} catch {
|
|
1251
|
+
// Fallback: just check if the name appears in the output
|
|
1252
|
+
exists = stdout.includes(name);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
qmdCollectionStatusCache.set(name, { checkedAt: Date.now(), exists });
|
|
1256
|
+
resolve(exists);
|
|
1257
|
+
});
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
export function scheduleQmdUpdate() {
|
|
1262
|
+
if (getQmdUpdateMode() !== "background") return;
|
|
1263
|
+
if (!qmdAvailable) return;
|
|
1264
|
+
if (updateTimer) clearTimeout(updateTimer);
|
|
1265
|
+
updateTimer = setTimeout(() => {
|
|
1266
|
+
updateTimer = null;
|
|
1267
|
+
execFileFn("qmd", ["update"], { timeout: 30_000 }, () => {});
|
|
1268
|
+
}, 500);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
async function runQmdUpdateNow() {
|
|
1272
|
+
if (getQmdUpdateMode() !== "background") return;
|
|
1273
|
+
if (!qmdAvailable) return;
|
|
1274
|
+
await new Promise<void>((resolve) => {
|
|
1275
|
+
execFileFn("qmd", ["update"], { timeout: 30_000 }, () => resolve());
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/** Search for memories relevant to the user's prompt. Returns formatted markdown or empty string on error. */
|
|
1280
|
+
export async function searchRelevantMemories(prompt: string): Promise<string> {
|
|
1281
|
+
if (!qmdAvailable || !prompt.trim()) return "";
|
|
1282
|
+
|
|
1283
|
+
// Sanitize: strip control chars, limit to 200 chars for the search query
|
|
1284
|
+
const sanitized = prompt
|
|
1285
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: we intentionally strip control chars.
|
|
1286
|
+
.replace(/[\x00-\x1f\x7f]/g, " ")
|
|
1287
|
+
.trim()
|
|
1288
|
+
.slice(0, 200);
|
|
1289
|
+
if (!sanitized) return "";
|
|
1290
|
+
|
|
1291
|
+
try {
|
|
1292
|
+
const hasCollection = await checkCollection("pi-memory");
|
|
1293
|
+
if (!hasCollection) return "";
|
|
1294
|
+
|
|
1295
|
+
const results = await Promise.race([
|
|
1296
|
+
runQmdSearch("keyword", sanitized, 3),
|
|
1297
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timeout")), 3_000)),
|
|
1298
|
+
]);
|
|
1299
|
+
|
|
1300
|
+
if (!results || results.results.length === 0) return "";
|
|
1301
|
+
|
|
1302
|
+
const snippets = results.results
|
|
1303
|
+
.map((r) => {
|
|
1304
|
+
const text = getQmdResultText(r);
|
|
1305
|
+
if (!text.trim()) return null;
|
|
1306
|
+
const filePath = getQmdResultPath(r);
|
|
1307
|
+
const filePart = filePath ? `_${filePath}_` : "";
|
|
1308
|
+
return filePart ? `${filePart}\n${text.trim()}` : text.trim();
|
|
1309
|
+
})
|
|
1310
|
+
.filter(Boolean);
|
|
1311
|
+
|
|
1312
|
+
if (snippets.length === 0) return "";
|
|
1313
|
+
return snippets.join("\n\n---\n\n");
|
|
1314
|
+
} catch {
|
|
1315
|
+
return "";
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
export interface QmdSearchResult {
|
|
1320
|
+
path?: string;
|
|
1321
|
+
file?: string;
|
|
1322
|
+
score?: number;
|
|
1323
|
+
content?: string;
|
|
1324
|
+
chunk?: string;
|
|
1325
|
+
snippet?: string;
|
|
1326
|
+
title?: string;
|
|
1327
|
+
[key: string]: unknown;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function getQmdResultPath(r: QmdSearchResult): string | undefined {
|
|
1331
|
+
return r.path ?? r.file;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function getQmdResultText(r: QmdSearchResult): string {
|
|
1335
|
+
return r.content ?? r.chunk ?? r.snippet ?? "";
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function stripAnsi(text: string): string {
|
|
1339
|
+
// qmd may emit spinners/progress bars even with --json, especially on first model download.
|
|
1340
|
+
// Strip ANSI CSI/OSC sequences so we can reliably find and parse JSON payloads.
|
|
1341
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI escape sequences
|
|
1342
|
+
return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "").replace(/\u001b\][^\u0007]*(\u0007|\u001b\\)/g, "");
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function parseQmdJson(stdout: string): unknown {
|
|
1346
|
+
const trimmed = stdout.trim();
|
|
1347
|
+
if (!trimmed) return [];
|
|
1348
|
+
if (trimmed === "No results found." || trimmed === "No results found") return [];
|
|
1349
|
+
|
|
1350
|
+
const cleaned = stripAnsi(stdout);
|
|
1351
|
+
const lines = cleaned.split(/\r?\n/);
|
|
1352
|
+
const startLine = lines.findIndex((l) => {
|
|
1353
|
+
const s = l.trimStart();
|
|
1354
|
+
return s.startsWith("[") || s.startsWith("{");
|
|
1355
|
+
});
|
|
1356
|
+
if (startLine === -1) {
|
|
1357
|
+
throw new Error(`Failed to parse qmd output: ${trimmed.slice(0, 200)}`);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const jsonText = lines.slice(startLine).join("\n").trim();
|
|
1361
|
+
if (!jsonText) return [];
|
|
1362
|
+
return JSON.parse(jsonText);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
export function runQmdSearch(
|
|
1366
|
+
mode: "keyword" | "semantic" | "deep",
|
|
1367
|
+
query: string,
|
|
1368
|
+
limit: number,
|
|
1369
|
+
): Promise<{ results: QmdSearchResult[]; stderr: string }> {
|
|
1370
|
+
const subcommand = mode === "keyword" ? "search" : mode === "semantic" ? "vsearch" : "query";
|
|
1371
|
+
const args = [subcommand, "--json", "-c", "pi-memory", "-n", String(limit), query];
|
|
1372
|
+
|
|
1373
|
+
return new Promise((resolve, reject) => {
|
|
1374
|
+
execFileFn("qmd", args, { timeout: 60_000 }, (err, stdout, stderr) => {
|
|
1375
|
+
if (err) {
|
|
1376
|
+
reject(new Error(stderr?.trim() || err.message));
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
try {
|
|
1380
|
+
const parsed = parseQmdJson(stdout);
|
|
1381
|
+
const results = Array.isArray(parsed) ? parsed : ((parsed as any).results ?? (parsed as any).hits ?? []);
|
|
1382
|
+
resolve({ results, stderr: stderr ?? "" });
|
|
1383
|
+
} catch (parseErr) {
|
|
1384
|
+
if (parseErr instanceof Error) {
|
|
1385
|
+
reject(parseErr);
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
reject(new Error(`Failed to parse qmd output: ${stdout.slice(0, 200)}`));
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// ---------------------------------------------------------------------------
|
|
1395
|
+
// Memory snapshot (Option P: KV cache-stable context injection)
|
|
1396
|
+
//
|
|
1397
|
+
// The system prompt must be byte-stable across turns so local prefix caches
|
|
1398
|
+
// (llama.cpp, vLLM, MLX) don't invalidate the entire conversation tail on each
|
|
1399
|
+
// turn. We snapshot the memory context at deliberate checkpoints
|
|
1400
|
+
// (session_start, session_before_compact, long_term writes, day rollover) and
|
|
1401
|
+
// emit the same bytes for every turn in between.
|
|
1402
|
+
// ---------------------------------------------------------------------------
|
|
1403
|
+
|
|
1404
|
+
let memorySnapshot: string | null = null;
|
|
1405
|
+
let snapshotTakenAt: string | null = null;
|
|
1406
|
+
let snapshotTakenOnDate: string | null = null;
|
|
1407
|
+
let snapshotReason: string | null = null;
|
|
1408
|
+
let snapshotDirty = false;
|
|
1409
|
+
|
|
1410
|
+
function refreshMemorySnapshot(reason: string) {
|
|
1411
|
+
memorySnapshot = buildMemoryContext("");
|
|
1412
|
+
snapshotTakenAt = nowTimestamp();
|
|
1413
|
+
snapshotTakenOnDate = todayStr();
|
|
1414
|
+
snapshotReason = reason;
|
|
1415
|
+
snapshotDirty = false;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
function getSnapshotMode(): "stable" | "per-turn" {
|
|
1419
|
+
const mode = (process.env.PI_MEMORY_SNAPSHOT ?? "stable").toLowerCase();
|
|
1420
|
+
return mode === "per-turn" ? "per-turn" : "stable";
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
async function runCurator(reason: string): Promise<string> {
|
|
1425
|
+
ensureDirs();
|
|
1426
|
+
const store = new FileMemoryStore(MEMORY_DIR);
|
|
1427
|
+
const result = await runMemoryCuratorOnce({
|
|
1428
|
+
memoryStore: store,
|
|
1429
|
+
auditLog: new JsonlAuditLog(MEMORY_DIR),
|
|
1430
|
+
reason,
|
|
1431
|
+
});
|
|
1432
|
+
const lifecycleResult = await applyReviewLifecycle(store);
|
|
1433
|
+
const memoryResult = await proposeMemoryPromotions(store);
|
|
1434
|
+
const skillResult = getMemorySkillDraftsMode() === "off" ? { created: 0, proposals: [] } : await proposeSkillDrafts(store, { draftsDir: SKILL_DRAFTS_DIR });
|
|
1435
|
+
let autoApprovedMemory = 0;
|
|
1436
|
+
let autoApprovedSkills = 0;
|
|
1437
|
+
if (getMemoryAutoApproveMemory()) {
|
|
1438
|
+
for (const id of memoryResult.proposalIds) {
|
|
1439
|
+
await approveMemoryPromotion(store, id);
|
|
1440
|
+
autoApprovedMemory += 1;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
if (getMemoryAutoApproveSkillDrafts()) {
|
|
1444
|
+
for (const proposal of skillResult.proposals) {
|
|
1445
|
+
await approveSkillDraft(store, proposal.id);
|
|
1446
|
+
autoApprovedSkills += 1;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
const learningChanges = lifecycleResult.changed + memoryResult.created + skillResult.created + autoApprovedMemory + autoApprovedSkills;
|
|
1450
|
+
if (result.patches.length > 0 || learningChanges > 0) {
|
|
1451
|
+
snapshotDirty = true;
|
|
1452
|
+
await ensureQmdAvailableForUpdate();
|
|
1453
|
+
scheduleQmdUpdate();
|
|
1454
|
+
}
|
|
1455
|
+
const notes = [
|
|
1456
|
+
memoryResult.created > 0 ? `proposed ${memoryResult.created} memory promotion(s)` : "",
|
|
1457
|
+
skillResult.created > 0 ? `proposed ${skillResult.created} skill draft(s)` : "",
|
|
1458
|
+
autoApprovedMemory > 0 ? `auto-approved ${autoApprovedMemory} memory promotion(s)` : "",
|
|
1459
|
+
autoApprovedSkills > 0 ? `auto-approved ${autoApprovedSkills} skill draft(s)` : "",
|
|
1460
|
+
].filter(Boolean);
|
|
1461
|
+
return notes.length > 0 ? `${result.summary}; ${notes.join("; ")}` : result.summary;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/** Reset snapshot state (for testing). */
|
|
1465
|
+
export function _resetMemorySnapshot() {
|
|
1466
|
+
memorySnapshot = null;
|
|
1467
|
+
snapshotTakenAt = null;
|
|
1468
|
+
snapshotTakenOnDate = null;
|
|
1469
|
+
snapshotReason = null;
|
|
1470
|
+
snapshotDirty = false;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// ---------------------------------------------------------------------------
|
|
1474
|
+
// Extension entry point
|
|
1475
|
+
// ---------------------------------------------------------------------------
|
|
1476
|
+
|
|
1477
|
+
export default function (pi: ExtensionAPI) {
|
|
1478
|
+
// --- session_start: detect qmd, auto-setup collection ---
|
|
1479
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1480
|
+
ensureDirs();
|
|
1481
|
+
exitSummaryReason = null;
|
|
1482
|
+
if (terminalInputUnsubscribe) {
|
|
1483
|
+
terminalInputUnsubscribe();
|
|
1484
|
+
terminalInputUnsubscribe = null;
|
|
1485
|
+
}
|
|
1486
|
+
if (ctx.hasUI) {
|
|
1487
|
+
terminalInputUnsubscribe = ctx.ui.onTerminalInput((data) => {
|
|
1488
|
+
if (!data.includes("\u0004")) return undefined;
|
|
1489
|
+
if (!ctx.isIdle()) return undefined;
|
|
1490
|
+
if (ctx.ui.getEditorText().trim()) return undefined;
|
|
1491
|
+
exitSummaryReason = "ctrl+d";
|
|
1492
|
+
return undefined;
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
qmdAvailable = await detectQmd();
|
|
1497
|
+
if (!qmdAvailable) {
|
|
1498
|
+
if (ctx.hasUI) {
|
|
1499
|
+
ctx.ui.notify(qmdInstallInstructions(), "info");
|
|
1500
|
+
}
|
|
1501
|
+
refreshMemorySnapshot("session_start");
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
const hasCollection = await checkCollection("pi-memory");
|
|
1506
|
+
if (!hasCollection) {
|
|
1507
|
+
await setupQmdCollection();
|
|
1508
|
+
}
|
|
1509
|
+
refreshMemorySnapshot("session_start");
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
// --- session_shutdown: write exit summary + clean up timer ---
|
|
1513
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
1514
|
+
const shutdownReason = (event as { reason?: string }).reason;
|
|
1515
|
+
|
|
1516
|
+
if (terminalInputUnsubscribe) {
|
|
1517
|
+
terminalInputUnsubscribe();
|
|
1518
|
+
terminalInputUnsubscribe = null;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// Lifecycle transitions are usually not final session exits. By default,
|
|
1522
|
+
// avoid generating LLM summaries during /reload, /new, /resume, and
|
|
1523
|
+
// /fork because that makes transitions slow. Keep /reload and /resume
|
|
1524
|
+
// silent, but write a cheap handoff for /new and /fork so useful context
|
|
1525
|
+
// survives the transition. Users who prefer full summaries can opt in
|
|
1526
|
+
// with PI_MEMORY_SUMMARIZE_TRANSITIONS=1.
|
|
1527
|
+
if (shouldSkipExitSummaryForReason(shutdownReason)) {
|
|
1528
|
+
try {
|
|
1529
|
+
if (shouldWriteTransitionHandoffForReason(shutdownReason)) {
|
|
1530
|
+
await writeTransitionHandoff(ctx, shutdownReason);
|
|
1531
|
+
}
|
|
1532
|
+
} finally {
|
|
1533
|
+
exitSummaryReason = null;
|
|
1534
|
+
if (updateTimer) {
|
|
1535
|
+
clearTimeout(updateTimer);
|
|
1536
|
+
updateTimer = null;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
const reason = exitSummaryReason ?? "session-end";
|
|
1543
|
+
exitSummaryReason = null;
|
|
1544
|
+
|
|
1545
|
+
try {
|
|
1546
|
+
if (reason) {
|
|
1547
|
+
ensureDirs();
|
|
1548
|
+
const result = await generateExitSummary(ctx);
|
|
1549
|
+
if (result.hasMessages) {
|
|
1550
|
+
const summary = result.summary ?? buildExitSummaryFallback(result.error);
|
|
1551
|
+
const sid = shortSessionId(ctx.sessionManager.getSessionId());
|
|
1552
|
+
const ts = nowTimestamp();
|
|
1553
|
+
const entry = formatExitSummaryEntry(summary, reason, sid, ts);
|
|
1554
|
+
const filePath = dailyPath(todayStr());
|
|
1555
|
+
const existing = readFileSafe(filePath) ?? "";
|
|
1556
|
+
const separator = existing.trim() ? "\n\n" : "";
|
|
1557
|
+
fs.writeFileSync(filePath, existing + separator + entry, "utf-8");
|
|
1558
|
+
await runSessionLearningExtractor(ctx);
|
|
1559
|
+
await ensureQmdAvailableForUpdate();
|
|
1560
|
+
await runQmdUpdateNow();
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
} finally {
|
|
1564
|
+
if (updateTimer) {
|
|
1565
|
+
clearTimeout(updateTimer);
|
|
1566
|
+
updateTimer = null;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
// --- input: detect /quit for shutdown summary ---
|
|
1572
|
+
pi.on("input", async (event, _ctx) => {
|
|
1573
|
+
if (event.source !== "extension" && event.text.trim() === "/quit") {
|
|
1574
|
+
exitSummaryReason = "slash-quit";
|
|
1575
|
+
}
|
|
1576
|
+
return { action: "continue" };
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
// --- Inject memory context before every agent turn ---
|
|
1580
|
+
pi.on("before_agent_start", async (event, _ctx) => {
|
|
1581
|
+
const mode = getSnapshotMode();
|
|
1582
|
+
|
|
1583
|
+
let memoryContext: string;
|
|
1584
|
+
let snapshotCaveat = "";
|
|
1585
|
+
|
|
1586
|
+
if (mode === "per-turn") {
|
|
1587
|
+
const skipSearch = process.env.PI_MEMORY_NO_SEARCH === "1";
|
|
1588
|
+
const searchResults = skipSearch ? "" : await searchRelevantMemories(event.prompt ?? "");
|
|
1589
|
+
memoryContext = buildMemoryContext(searchResults);
|
|
1590
|
+
} else {
|
|
1591
|
+
const today = todayStr();
|
|
1592
|
+
const needsRefresh = memorySnapshot === null || snapshotDirty || snapshotTakenOnDate !== today;
|
|
1593
|
+
if (needsRefresh) {
|
|
1594
|
+
const reason =
|
|
1595
|
+
memorySnapshot === null ? "before_agent_start" : snapshotDirty ? "long_term_write" : "day_rollover";
|
|
1596
|
+
refreshMemorySnapshot(reason);
|
|
1597
|
+
}
|
|
1598
|
+
memoryContext = memorySnapshot ?? "";
|
|
1599
|
+
snapshotCaveat =
|
|
1600
|
+
`Snapshot ${snapshotReason} at ${snapshotTakenAt}. ` +
|
|
1601
|
+
"Use memory_read / memory_search for the authoritative latest state; " +
|
|
1602
|
+
"recent writes may also be visible in tool-call history.";
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
if (!memoryContext) return;
|
|
1606
|
+
|
|
1607
|
+
const headerLines = ["\n\n## Memory"];
|
|
1608
|
+
if (snapshotCaveat) headerLines.push(`(${snapshotCaveat})`);
|
|
1609
|
+
headerLines.push(
|
|
1610
|
+
"The following memory files have been loaded. Use the memory_write tool to persist important information.",
|
|
1611
|
+
"- Decisions, preferences, and durable facts \u2192 MEMORY.md",
|
|
1612
|
+
"- Structured user profile \u2192 USER.md",
|
|
1613
|
+
"- Current dated state, events, temporary facts, and quotas \u2192 STATE.md",
|
|
1614
|
+
"- Memory review queue \u2192 REVIEW.md",
|
|
1615
|
+
"- Day-to-day notes and running context \u2192 daily/<YYYY-MM-DD>.md",
|
|
1616
|
+
"- Things to fix later or keep in mind \u2192 scratchpad tool",
|
|
1617
|
+
"- Use memory_search to find past context across all memory files (keyword, semantic, or deep search).",
|
|
1618
|
+
"- Use #tags (e.g. #decision, #preference) and [[links]] (e.g. [[auth-strategy]]) in memory content to improve future search recall.",
|
|
1619
|
+
'- If someone says "remember this," write it immediately.',
|
|
1620
|
+
"",
|
|
1621
|
+
memoryContext,
|
|
1622
|
+
);
|
|
1623
|
+
|
|
1624
|
+
return {
|
|
1625
|
+
systemPrompt: event.systemPrompt + headerLines.join("\n"),
|
|
1626
|
+
};
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
// --- Pre-compaction: auto-capture session handoff ---
|
|
1630
|
+
pi.on("session_before_compact", async (_event, ctx) => {
|
|
1631
|
+
ensureDirs();
|
|
1632
|
+
const sid = shortSessionId(ctx.sessionManager.getSessionId());
|
|
1633
|
+
const ts = nowTimestamp();
|
|
1634
|
+
const parts: string[] = [];
|
|
1635
|
+
|
|
1636
|
+
// Capture open scratchpad items
|
|
1637
|
+
const scratchpad = readFileSafe(SCRATCHPAD_FILE);
|
|
1638
|
+
if (scratchpad?.trim()) {
|
|
1639
|
+
const openItems = parseScratchpad(scratchpad).filter((i) => !i.done);
|
|
1640
|
+
if (openItems.length > 0) {
|
|
1641
|
+
parts.push("**Open scratchpad items:**");
|
|
1642
|
+
for (const item of openItems) {
|
|
1643
|
+
parts.push(`- [ ] ${item.text}`);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// Capture last few lines from today's daily log
|
|
1649
|
+
const todayContent = readFileSafe(dailyPath(todayStr()));
|
|
1650
|
+
if (todayContent?.trim()) {
|
|
1651
|
+
const lines = todayContent.trim().split("\n");
|
|
1652
|
+
const tail = lines.slice(-15).join("\n");
|
|
1653
|
+
parts.push(`**Recent daily log context:**\n${tail}`);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// Intentional cache boundary: compaction drops tool history, so the
|
|
1657
|
+
// snapshot must catch up to disk on every compaction — even when no
|
|
1658
|
+
// handoff is written. Otherwise stale pre-compaction state (e.g. a
|
|
1659
|
+
// completed scratchpad item that no longer appears in the snapshot
|
|
1660
|
+
// source files) would keep being injected.
|
|
1661
|
+
try {
|
|
1662
|
+
if (parts.length === 0) return;
|
|
1663
|
+
|
|
1664
|
+
const handoff = [`<!-- HANDOFF ${ts} [${sid}] -->`, "## Session Handoff", ...parts].join("\n");
|
|
1665
|
+
|
|
1666
|
+
const filePath = dailyPath(todayStr());
|
|
1667
|
+
const existing = readFileSafe(filePath) ?? "";
|
|
1668
|
+
const separator = existing.trim() ? "\n\n" : "";
|
|
1669
|
+
fs.writeFileSync(filePath, existing + separator + handoff, "utf-8");
|
|
1670
|
+
await ensureQmdAvailableForUpdate();
|
|
1671
|
+
scheduleQmdUpdate();
|
|
1672
|
+
} finally {
|
|
1673
|
+
refreshMemorySnapshot("session_before_compact");
|
|
1674
|
+
}
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
// --- memory_write tool ---
|
|
1678
|
+
pi.registerTool({
|
|
1679
|
+
name: "memory_write",
|
|
1680
|
+
label: "Memory Write",
|
|
1681
|
+
description: [
|
|
1682
|
+
"Write to memory files. Supports plain pi-memory targets plus structured time-aware targets:",
|
|
1683
|
+
"- 'long_term': append/overwrite MEMORY.md for durable facts, decisions, and preferences.",
|
|
1684
|
+
"- 'daily': append to today's daily log (daily/<YYYY-MM-DD>.md).",
|
|
1685
|
+
"- 'state': append structured time-aware entries to STATE.md. Use this for events, temporary facts, quotas, and dated state.",
|
|
1686
|
+
"- 'user': append structured user-profile entries to USER.md.",
|
|
1687
|
+
"- 'review': append structured review entries to REVIEW.md.",
|
|
1688
|
+
"For time-sensitive facts, prefer target='state' with type='event', 'temporary', or 'quota' so the curator can maintain them.",
|
|
1689
|
+
].join("\n"),
|
|
1690
|
+
promptSnippet: "Write memory. Use target='state' plus metadata for time-sensitive event/temporary/quota facts so the curator can maintain them.",
|
|
1691
|
+
parameters: Type.Object({
|
|
1692
|
+
target: StringEnum(MEMORY_WRITE_TARGETS, { description: "Where to write memory" }),
|
|
1693
|
+
content: Type.String({ description: "Memory content" }),
|
|
1694
|
+
mode: Type.Optional(StringEnum(["append", "overwrite"] as const, { description: "Only applies to long_term. Default: append." })),
|
|
1695
|
+
type: Type.Optional(StringEnum(STRUCTURED_MEMORY_TYPES, { description: "Structured metadata type for state/user/review targets" })),
|
|
1696
|
+
status: Type.Optional(Type.String({ description: "Structured status, e.g. planned, today, past, active, exhausted, archived" })),
|
|
1697
|
+
date: Type.Optional(Type.String({ description: "Date for event/temporary entries, YYYY-MM-DD" })),
|
|
1698
|
+
reset: Type.Optional(Type.String({ description: "Quota reset date/time" })),
|
|
1699
|
+
month: Type.Optional(Type.String({ description: "Quota month, YYYY-MM" })),
|
|
1700
|
+
provider: Type.Optional(Type.String({ description: "Quota provider" })),
|
|
1701
|
+
used: Type.Optional(Type.String({ description: "Quota used count" })),
|
|
1702
|
+
limit: Type.Optional(Type.String({ description: "Quota limit count" })),
|
|
1703
|
+
ttlDays: Type.Optional(Type.String({ description: "Temporary memory TTL in days" })),
|
|
1704
|
+
}),
|
|
1705
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1706
|
+
ensureDirs();
|
|
1707
|
+
const target = params.target as MemoryWriteTarget;
|
|
1708
|
+
const content = params.content.trim();
|
|
1709
|
+
if (!content) {
|
|
1710
|
+
return { content: [{ type: "text", text: "Error: content is required." }], isError: true, details: {} };
|
|
1711
|
+
}
|
|
1712
|
+
const sid = shortSessionId(ctx.sessionManager.getSessionId());
|
|
1713
|
+
const ts = nowTimestamp();
|
|
1714
|
+
|
|
1715
|
+
if (target === "daily") {
|
|
1716
|
+
const filePath = dailyPath(todayStr());
|
|
1717
|
+
const existing = readFileSafe(filePath) ?? "";
|
|
1718
|
+
const existingPreview = buildPreview(existing, {
|
|
1719
|
+
maxLines: RESPONSE_PREVIEW_MAX_LINES,
|
|
1720
|
+
maxChars: RESPONSE_PREVIEW_MAX_CHARS,
|
|
1721
|
+
mode: "end",
|
|
1722
|
+
});
|
|
1723
|
+
const existingSnippet = existingPreview.preview
|
|
1724
|
+
? `\n\n${formatPreviewBlock("Existing daily log preview", existing, "end")}`
|
|
1725
|
+
: "\n\nDaily log was empty.";
|
|
1726
|
+
|
|
1727
|
+
const separator = existing.trim() ? "\n\n" : "";
|
|
1728
|
+
const stamped = `<!-- ${ts} [${sid}] -->\n${content}`;
|
|
1729
|
+
fs.writeFileSync(filePath, existing + separator + stamped, "utf-8");
|
|
1730
|
+
await ensureQmdAvailableForUpdate();
|
|
1731
|
+
scheduleQmdUpdate();
|
|
1732
|
+
return {
|
|
1733
|
+
content: [{ type: "text", text: `Appended to daily log: ${filePath}${existingSnippet}` }],
|
|
1734
|
+
details: { path: filePath, target, mode: "append", sessionId: sid, timestamp: ts, qmdUpdateMode: getQmdUpdateMode(), existingPreview },
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
if (target === "long_term") {
|
|
1739
|
+
const existing = readFileSafe(MEMORY_FILE) ?? "";
|
|
1740
|
+
const existingPreview = buildPreview(existing, {
|
|
1741
|
+
maxLines: RESPONSE_PREVIEW_MAX_LINES,
|
|
1742
|
+
maxChars: RESPONSE_PREVIEW_MAX_CHARS,
|
|
1743
|
+
mode: "middle",
|
|
1744
|
+
});
|
|
1745
|
+
const existingSnippet = existingPreview.preview
|
|
1746
|
+
? `\n\n${formatPreviewBlock("Existing MEMORY.md preview", existing, "middle")}`
|
|
1747
|
+
: "\n\nMEMORY.md was empty.";
|
|
1748
|
+
|
|
1749
|
+
snapshotDirty = true;
|
|
1750
|
+
if (params.mode === "overwrite") {
|
|
1751
|
+
const stamped = `<!-- last updated: ${ts} [${sid}] -->\n${content}`;
|
|
1752
|
+
fs.writeFileSync(MEMORY_FILE, stamped, "utf-8");
|
|
1753
|
+
await ensureQmdAvailableForUpdate();
|
|
1754
|
+
scheduleQmdUpdate();
|
|
1755
|
+
return {
|
|
1756
|
+
content: [{ type: "text", text: `Overwrote MEMORY.md${existingSnippet}` }],
|
|
1757
|
+
details: { path: MEMORY_FILE, target, mode: "overwrite", sessionId: sid, timestamp: ts, qmdUpdateMode: getQmdUpdateMode(), existingPreview },
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
const separator = existing.trim() ? "\n\n" : "";
|
|
1762
|
+
const stamped = `<!-- ${ts} [${sid}] -->\n${content}`;
|
|
1763
|
+
fs.writeFileSync(MEMORY_FILE, existing + separator + stamped, "utf-8");
|
|
1764
|
+
await ensureQmdAvailableForUpdate();
|
|
1765
|
+
scheduleQmdUpdate();
|
|
1766
|
+
return {
|
|
1767
|
+
content: [{ type: "text", text: `Appended to MEMORY.md${existingSnippet}` }],
|
|
1768
|
+
details: { path: MEMORY_FILE, target, mode: "append", sessionId: sid, timestamp: ts, qmdUpdateMode: getQmdUpdateMode(), existingPreview },
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
const entry = buildStructuredEntry({
|
|
1773
|
+
target,
|
|
1774
|
+
content,
|
|
1775
|
+
type: params.type,
|
|
1776
|
+
status: params.status,
|
|
1777
|
+
date: params.date,
|
|
1778
|
+
reset: params.reset,
|
|
1779
|
+
month: params.month,
|
|
1780
|
+
provider: params.provider,
|
|
1781
|
+
used: params.used,
|
|
1782
|
+
limit: params.limit,
|
|
1783
|
+
ttlDays: params.ttlDays,
|
|
1784
|
+
});
|
|
1785
|
+
const added = addStructuredEntry(target, entry);
|
|
1786
|
+
snapshotDirty = true;
|
|
1787
|
+
await ensureQmdAvailableForUpdate();
|
|
1788
|
+
scheduleQmdUpdate();
|
|
1789
|
+
return {
|
|
1790
|
+
content: [{ type: "text", text: added ? `Added to ${target}: ${structuredMemoryPath(target)}` : `Entry already exists in ${target}: ${structuredMemoryPath(target)}` }],
|
|
1791
|
+
details: { path: structuredMemoryPath(target), target, mode: "append", sessionId: sid, timestamp: ts, entry, qmdUpdateMode: getQmdUpdateMode() },
|
|
1792
|
+
};
|
|
1793
|
+
},
|
|
1794
|
+
});
|
|
1795
|
+
|
|
1796
|
+
// --- scratchpad tool ---
|
|
1797
|
+
pi.registerTool({
|
|
1798
|
+
name: "scratchpad",
|
|
1799
|
+
label: "Scratchpad",
|
|
1800
|
+
description: [
|
|
1801
|
+
"Manage a checklist of things to fix later or keep in mind. Actions:",
|
|
1802
|
+
"- 'add': Add a new unchecked item (- [ ] text)",
|
|
1803
|
+
"- 'done': Mark an item as done (- [x] text). Match by substring.",
|
|
1804
|
+
"- 'undo': Uncheck a done item back to open. Match by substring.",
|
|
1805
|
+
"- 'clear_done': Remove all checked items from the list.",
|
|
1806
|
+
"- 'list': Show all items.",
|
|
1807
|
+
].join("\n"),
|
|
1808
|
+
parameters: Type.Object({
|
|
1809
|
+
action: StringEnum(["add", "done", "undo", "clear_done", "list"] as const, {
|
|
1810
|
+
description: "What to do",
|
|
1811
|
+
}),
|
|
1812
|
+
text: Type.Optional(
|
|
1813
|
+
Type.String({
|
|
1814
|
+
description: "Item text for add, or substring to match for done/undo",
|
|
1815
|
+
}),
|
|
1816
|
+
),
|
|
1817
|
+
}),
|
|
1818
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1819
|
+
ensureDirs();
|
|
1820
|
+
const { action, text } = params;
|
|
1821
|
+
const sid = shortSessionId(ctx.sessionManager.getSessionId());
|
|
1822
|
+
const ts = nowTimestamp();
|
|
1823
|
+
|
|
1824
|
+
const existing = readFileSafe(SCRATCHPAD_FILE) ?? "";
|
|
1825
|
+
let items = parseScratchpad(existing);
|
|
1826
|
+
|
|
1827
|
+
if (action === "list") {
|
|
1828
|
+
if (items.length === 0) {
|
|
1829
|
+
return {
|
|
1830
|
+
content: [{ type: "text", text: "Scratchpad is empty." }],
|
|
1831
|
+
details: {},
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
const serialized = serializeScratchpad(items);
|
|
1835
|
+
const preview = buildPreview(serialized, {
|
|
1836
|
+
maxLines: RESPONSE_PREVIEW_MAX_LINES,
|
|
1837
|
+
maxChars: RESPONSE_PREVIEW_MAX_CHARS,
|
|
1838
|
+
mode: "start",
|
|
1839
|
+
});
|
|
1840
|
+
return {
|
|
1841
|
+
content: [
|
|
1842
|
+
{
|
|
1843
|
+
type: "text",
|
|
1844
|
+
text: formatPreviewBlock("Scratchpad preview", serialized, "start"),
|
|
1845
|
+
},
|
|
1846
|
+
],
|
|
1847
|
+
details: {
|
|
1848
|
+
count: items.length,
|
|
1849
|
+
open: items.filter((i) => !i.done).length,
|
|
1850
|
+
preview,
|
|
1851
|
+
},
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
if (action === "add") {
|
|
1856
|
+
if (!text) {
|
|
1857
|
+
return {
|
|
1858
|
+
content: [{ type: "text", text: "Error: 'text' is required for add." }],
|
|
1859
|
+
details: {},
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
items.push({ done: false, text, meta: `<!-- ${ts} [${sid}] -->` });
|
|
1863
|
+
const serialized = serializeScratchpad(items);
|
|
1864
|
+
const preview = buildPreview(serialized, {
|
|
1865
|
+
maxLines: RESPONSE_PREVIEW_MAX_LINES,
|
|
1866
|
+
maxChars: RESPONSE_PREVIEW_MAX_CHARS,
|
|
1867
|
+
mode: "start",
|
|
1868
|
+
});
|
|
1869
|
+
fs.writeFileSync(SCRATCHPAD_FILE, serialized, "utf-8");
|
|
1870
|
+
await ensureQmdAvailableForUpdate();
|
|
1871
|
+
scheduleQmdUpdate();
|
|
1872
|
+
return {
|
|
1873
|
+
content: [
|
|
1874
|
+
{
|
|
1875
|
+
type: "text",
|
|
1876
|
+
text: `Added: - [ ] ${text}\n\n${formatPreviewBlock("Scratchpad preview", serialized, "start")}`,
|
|
1877
|
+
},
|
|
1878
|
+
],
|
|
1879
|
+
details: {
|
|
1880
|
+
action,
|
|
1881
|
+
sessionId: sid,
|
|
1882
|
+
timestamp: ts,
|
|
1883
|
+
qmdUpdateMode: getQmdUpdateMode(),
|
|
1884
|
+
preview,
|
|
1885
|
+
},
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
if (action === "done" || action === "undo") {
|
|
1890
|
+
if (!text) {
|
|
1891
|
+
return {
|
|
1892
|
+
content: [
|
|
1893
|
+
{
|
|
1894
|
+
type: "text",
|
|
1895
|
+
text: `Error: 'text' is required for ${action}.`,
|
|
1896
|
+
},
|
|
1897
|
+
],
|
|
1898
|
+
details: {},
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
const needle = text.toLowerCase();
|
|
1902
|
+
const targetDone = action === "done";
|
|
1903
|
+
let matched = false;
|
|
1904
|
+
for (const item of items) {
|
|
1905
|
+
if (item.done !== targetDone && item.text.toLowerCase().includes(needle)) {
|
|
1906
|
+
item.done = targetDone;
|
|
1907
|
+
matched = true;
|
|
1908
|
+
break;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
if (!matched) {
|
|
1912
|
+
return {
|
|
1913
|
+
content: [
|
|
1914
|
+
{
|
|
1915
|
+
type: "text",
|
|
1916
|
+
text: `No matching ${targetDone ? "open" : "done"} item found for: "${text}"`,
|
|
1917
|
+
},
|
|
1918
|
+
],
|
|
1919
|
+
details: {},
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
const serialized = serializeScratchpad(items);
|
|
1923
|
+
const preview = buildPreview(serialized, {
|
|
1924
|
+
maxLines: RESPONSE_PREVIEW_MAX_LINES,
|
|
1925
|
+
maxChars: RESPONSE_PREVIEW_MAX_CHARS,
|
|
1926
|
+
mode: "start",
|
|
1927
|
+
});
|
|
1928
|
+
fs.writeFileSync(SCRATCHPAD_FILE, serialized, "utf-8");
|
|
1929
|
+
await ensureQmdAvailableForUpdate();
|
|
1930
|
+
scheduleQmdUpdate();
|
|
1931
|
+
return {
|
|
1932
|
+
content: [
|
|
1933
|
+
{
|
|
1934
|
+
type: "text",
|
|
1935
|
+
text: `Updated.\n\n${formatPreviewBlock("Scratchpad preview", serialized, "start")}`,
|
|
1936
|
+
},
|
|
1937
|
+
],
|
|
1938
|
+
details: {
|
|
1939
|
+
action,
|
|
1940
|
+
sessionId: sid,
|
|
1941
|
+
timestamp: ts,
|
|
1942
|
+
qmdUpdateMode: getQmdUpdateMode(),
|
|
1943
|
+
preview,
|
|
1944
|
+
},
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
if (action === "clear_done") {
|
|
1949
|
+
const before = items.length;
|
|
1950
|
+
items = items.filter((i) => !i.done);
|
|
1951
|
+
const removed = before - items.length;
|
|
1952
|
+
const serialized = serializeScratchpad(items);
|
|
1953
|
+
const preview = buildPreview(serialized, {
|
|
1954
|
+
maxLines: RESPONSE_PREVIEW_MAX_LINES,
|
|
1955
|
+
maxChars: RESPONSE_PREVIEW_MAX_CHARS,
|
|
1956
|
+
mode: "start",
|
|
1957
|
+
});
|
|
1958
|
+
fs.writeFileSync(SCRATCHPAD_FILE, serialized, "utf-8");
|
|
1959
|
+
await ensureQmdAvailableForUpdate();
|
|
1960
|
+
scheduleQmdUpdate();
|
|
1961
|
+
return {
|
|
1962
|
+
content: [
|
|
1963
|
+
{
|
|
1964
|
+
type: "text",
|
|
1965
|
+
text: `Cleared ${removed} done item(s).\n\n${formatPreviewBlock("Scratchpad preview", serialized, "start")}`,
|
|
1966
|
+
},
|
|
1967
|
+
],
|
|
1968
|
+
details: {
|
|
1969
|
+
action,
|
|
1970
|
+
removed,
|
|
1971
|
+
qmdUpdateMode: getQmdUpdateMode(),
|
|
1972
|
+
preview,
|
|
1973
|
+
},
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
return {
|
|
1978
|
+
content: [{ type: "text", text: `Unknown action: ${action}` }],
|
|
1979
|
+
details: {},
|
|
1980
|
+
};
|
|
1981
|
+
},
|
|
1982
|
+
});
|
|
1983
|
+
|
|
1984
|
+
// --- memory_read tool ---
|
|
1985
|
+
pi.registerTool({
|
|
1986
|
+
name: "memory_read",
|
|
1987
|
+
label: "Memory Read",
|
|
1988
|
+
description: [
|
|
1989
|
+
"Read a memory file. Targets:",
|
|
1990
|
+
"- 'long_term': Read MEMORY.md",
|
|
1991
|
+
"- 'user': Read USER.md",
|
|
1992
|
+
"- 'state': Read STATE.md",
|
|
1993
|
+
"- 'review': Read REVIEW.md",
|
|
1994
|
+
"- 'all': Read MEMORY.md, USER.md, STATE.md, REVIEW.md, SCRATCHPAD.md, and today's daily log.",
|
|
1995
|
+
"- 'scratchpad': Read SCRATCHPAD.md",
|
|
1996
|
+
"- 'daily': Read a specific day's log (default: today). Pass date as YYYY-MM-DD.",
|
|
1997
|
+
"- 'list': List all daily log files.",
|
|
1998
|
+
].join("\n"),
|
|
1999
|
+
parameters: Type.Object({
|
|
2000
|
+
target: StringEnum(MEMORY_READ_TARGETS, { description: "What to read" }),
|
|
2001
|
+
date: Type.Optional(Type.String({ description: "Date for daily log (YYYY-MM-DD). Default: today." })),
|
|
2002
|
+
}),
|
|
2003
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
2004
|
+
ensureDirs();
|
|
2005
|
+
const target = params.target as MemoryReadTarget;
|
|
2006
|
+
const date = params.date;
|
|
2007
|
+
|
|
2008
|
+
if (target === "list") {
|
|
2009
|
+
try {
|
|
2010
|
+
const files = fs.readdirSync(DAILY_DIR).filter((f) => f.endsWith(".md")).sort().reverse();
|
|
2011
|
+
if (files.length === 0) return { content: [{ type: "text", text: "No daily logs found." }], details: {} };
|
|
2012
|
+
return { content: [{ type: "text", text: `Daily logs:\n${files.map((f) => `- ${f}`).join("\n")}` }], details: { files } };
|
|
2013
|
+
} catch {
|
|
2014
|
+
return { content: [{ type: "text", text: "No daily logs directory." }], details: {} };
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
if (target === "daily") {
|
|
2019
|
+
const d = date ?? todayStr();
|
|
2020
|
+
if (!isValidDailyDate(d)) {
|
|
2021
|
+
return { content: [{ type: "text", text: `Invalid date format: ${d}. Use YYYY-MM-DD.` }], isError: true, details: { date: d } };
|
|
2022
|
+
}
|
|
2023
|
+
const filePath = dailyPath(d);
|
|
2024
|
+
const content = readFileSafe(filePath);
|
|
2025
|
+
if (!content) return { content: [{ type: "text", text: `No daily log for ${d}.` }], details: {} };
|
|
2026
|
+
return { content: [{ type: "text", text: content }], details: { path: filePath, date: d } };
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
if (target === "scratchpad") {
|
|
2030
|
+
const content = readFileSafe(SCRATCHPAD_FILE);
|
|
2031
|
+
if (!content?.trim()) return { content: [{ type: "text", text: "SCRATCHPAD.md is empty or does not exist." }], details: {} };
|
|
2032
|
+
return { content: [{ type: "text", text: content }], details: { path: SCRATCHPAD_FILE } };
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
if (target === "all") {
|
|
2036
|
+
const parts: string[] = [];
|
|
2037
|
+
for (const structuredTarget of STRUCTURED_MEMORY_TARGETS) {
|
|
2038
|
+
const filePath = structuredMemoryPath(structuredTarget);
|
|
2039
|
+
const content = readFileSafe(filePath)?.trim();
|
|
2040
|
+
parts.push(`## ${path.basename(filePath)}\n\n${content || "empty"}`);
|
|
2041
|
+
}
|
|
2042
|
+
const scratchpad = readFileSafe(SCRATCHPAD_FILE)?.trim();
|
|
2043
|
+
parts.push(`## SCRATCHPAD.md\n\n${scratchpad || "empty"}`);
|
|
2044
|
+
const todayPath = dailyPath(todayStr());
|
|
2045
|
+
const todayContent = readFileSafe(todayPath)?.trim();
|
|
2046
|
+
parts.push(`## daily/${todayStr()}.md\n\n${todayContent || "empty"}`);
|
|
2047
|
+
return { content: [{ type: "text", text: parts.join("\n\n---\n\n") }], details: { target } };
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
const structuredTarget = target === "long_term" ? "memory" : normalizeStructuredMemoryTarget(target);
|
|
2051
|
+
const filePath = structuredMemoryPath(structuredTarget);
|
|
2052
|
+
const content = readFileSafe(filePath);
|
|
2053
|
+
if (!content?.trim()) return { content: [{ type: "text", text: `${path.basename(filePath)} is empty or does not exist.` }], details: {} };
|
|
2054
|
+
return { content: [{ type: "text", text: content }], details: { path: filePath } };
|
|
2055
|
+
},
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
// --- memory_edit tool ---
|
|
2059
|
+
pi.registerTool({
|
|
2060
|
+
name: "memory_edit",
|
|
2061
|
+
label: "Memory Edit",
|
|
2062
|
+
description: "Edit structured memory stores (MEMORY.md, USER.md, STATE.md, REVIEW.md) by entry delimiter. Supports read, add, replace, remove, replace_all, and compact.",
|
|
2063
|
+
promptSnippet: "Edit structured memory entries when a user asks to update or remove existing memory.",
|
|
2064
|
+
parameters: Type.Object({
|
|
2065
|
+
action: StringEnum(MEMORY_EDIT_ACTIONS, { description: "Memory edit action" }),
|
|
2066
|
+
target: Type.Optional(StringEnum(STRUCTURED_MEMORY_TARGETS, { description: "Memory store target" })),
|
|
2067
|
+
content: Type.Optional(Type.String({ description: "Entry content for add/replace/replace_all" })),
|
|
2068
|
+
oldText: Type.Optional(Type.String({ description: "Unique substring for replace/remove" })),
|
|
2069
|
+
}),
|
|
2070
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
2071
|
+
try {
|
|
2072
|
+
const action = params.action as MemoryEditAction;
|
|
2073
|
+
const target = normalizeStructuredMemoryTarget(params.target);
|
|
2074
|
+
let text: string;
|
|
2075
|
+
if (action === "read") text = formatStructuredEntries(target);
|
|
2076
|
+
else if (action === "add") {
|
|
2077
|
+
if (!params.content?.trim()) throw new Error("content is required");
|
|
2078
|
+
const added = addStructuredEntry(target, params.content);
|
|
2079
|
+
text = added ? `Added to ${target}.` : `Entry already exists in ${target}.`;
|
|
2080
|
+
} else if (action === "replace") text = replaceStructuredEntry(target, params.oldText || "", params.content || "");
|
|
2081
|
+
else if (action === "remove") text = removeStructuredEntry(target, params.oldText || "");
|
|
2082
|
+
else if (action === "replace_all") text = replaceAllStructuredEntries(target, params.content || "");
|
|
2083
|
+
else text = compactStructuredEntries(target);
|
|
2084
|
+
snapshotDirty = true;
|
|
2085
|
+
await ensureQmdAvailableForUpdate();
|
|
2086
|
+
scheduleQmdUpdate();
|
|
2087
|
+
return { content: [{ type: "text", text }], details: { action, target, path: structuredMemoryPath(target) } };
|
|
2088
|
+
} catch (error) {
|
|
2089
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2090
|
+
return { content: [{ type: "text", text: message }], details: { error: message }, isError: true };
|
|
2091
|
+
}
|
|
2092
|
+
},
|
|
2093
|
+
});
|
|
2094
|
+
|
|
2095
|
+
// --- memory learning tools ---
|
|
2096
|
+
pi.registerTool({
|
|
2097
|
+
name: "memory_learning_approve",
|
|
2098
|
+
label: "Memory Learning Approve",
|
|
2099
|
+
description: "Approve a target-specific learning proposal by exact id. Skill approvals create disabled skill drafts only.",
|
|
2100
|
+
parameters: Type.Object({
|
|
2101
|
+
id: Type.String({ description: "Exact proposal id, for example skill_abcd1234" }),
|
|
2102
|
+
}),
|
|
2103
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
2104
|
+
try {
|
|
2105
|
+
const store = new FileMemoryStore(MEMORY_DIR);
|
|
2106
|
+
try {
|
|
2107
|
+
const memoryResult = await approveMemoryPromotion(store, params.id);
|
|
2108
|
+
snapshotDirty = true;
|
|
2109
|
+
await ensureQmdAvailableForUpdate();
|
|
2110
|
+
scheduleQmdUpdate();
|
|
2111
|
+
return { content: [{ type: "text", text: `Approved ${memoryResult.proposalId}. Wrote ${memoryResult.target}.` }], details: memoryResult };
|
|
2112
|
+
} catch {
|
|
2113
|
+
const skillResult = await approveSkillDraft(store, params.id);
|
|
2114
|
+
snapshotDirty = true;
|
|
2115
|
+
await ensureQmdAvailableForUpdate();
|
|
2116
|
+
scheduleQmdUpdate();
|
|
2117
|
+
return { content: [{ type: "text", text: `Approved ${skillResult.proposalId}. Created skill draft: ${skillResult.path}` }], details: skillResult };
|
|
2118
|
+
}
|
|
2119
|
+
} catch (error) {
|
|
2120
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2121
|
+
return { content: [{ type: "text", text: message }], details: { error: message }, isError: true };
|
|
2122
|
+
}
|
|
2123
|
+
},
|
|
2124
|
+
});
|
|
2125
|
+
|
|
2126
|
+
pi.registerTool({
|
|
2127
|
+
name: "memory_learning_reject",
|
|
2128
|
+
label: "Memory Learning Reject",
|
|
2129
|
+
description: "Reject or archive a review candidate/proposal by exact id without deleting it.",
|
|
2130
|
+
parameters: Type.Object({
|
|
2131
|
+
id: Type.String({ description: "Exact review id" }),
|
|
2132
|
+
status: Type.Optional(StringEnum(["rejected", "archived"] as const, { description: "Status to apply. Default rejected." })),
|
|
2133
|
+
}),
|
|
2134
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
2135
|
+
try {
|
|
2136
|
+
await rejectReviewItem(new FileMemoryStore(MEMORY_DIR), params.id, params.status as "rejected" | "archived" | undefined);
|
|
2137
|
+
snapshotDirty = true;
|
|
2138
|
+
await ensureQmdAvailableForUpdate();
|
|
2139
|
+
scheduleQmdUpdate();
|
|
2140
|
+
return { content: [{ type: "text", text: `Marked ${params.id} as ${params.status || "rejected"}.` }], details: { id: params.id, status: params.status || "rejected" } };
|
|
2141
|
+
} catch (error) {
|
|
2142
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2143
|
+
return { content: [{ type: "text", text: message }], details: { error: message }, isError: true };
|
|
2144
|
+
}
|
|
2145
|
+
},
|
|
2146
|
+
});
|
|
2147
|
+
|
|
2148
|
+
pi.registerTool({
|
|
2149
|
+
name: "memory_skill_drafts",
|
|
2150
|
+
label: "Memory Skill Drafts",
|
|
2151
|
+
description: "List proposed skill drafts from REVIEW.md.",
|
|
2152
|
+
parameters: Type.Object({}),
|
|
2153
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
2154
|
+
const proposals = await listSkillDraftProposals(new FileMemoryStore(MEMORY_DIR));
|
|
2155
|
+
if (proposals.length === 0) return { content: [{ type: "text", text: "No skill draft proposals." }], details: { proposals } };
|
|
2156
|
+
const text = proposals.map((proposal) => [`- ${proposal.id}: ${proposal.title}`, ` ${proposal.description}`, ` -> ${proposal.promotesTo}`].join("\n")).join("\n");
|
|
2157
|
+
return { content: [{ type: "text", text }], details: { proposals } };
|
|
2158
|
+
},
|
|
2159
|
+
});
|
|
2160
|
+
|
|
2161
|
+
// --- memory_curate tool ---
|
|
2162
|
+
pi.registerTool({
|
|
2163
|
+
name: "memory_curate",
|
|
2164
|
+
label: "Memory Curate",
|
|
2165
|
+
description: "Run the time-aware memory curator now. It deduplicates exact entries, updates event/quota lifecycle metadata, and appends stale temporary memories to REVIEW.md.",
|
|
2166
|
+
parameters: Type.Object({}),
|
|
2167
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
2168
|
+
try {
|
|
2169
|
+
const summary = await runCurator("memory_curate tool");
|
|
2170
|
+
return { content: [{ type: "text", text: summary }], details: { summary } };
|
|
2171
|
+
} catch (error) {
|
|
2172
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2173
|
+
return { content: [{ type: "text", text: message }], details: { error: message }, isError: true };
|
|
2174
|
+
}
|
|
2175
|
+
},
|
|
2176
|
+
});
|
|
2177
|
+
|
|
2178
|
+
|
|
2179
|
+
// --- memory curator service tools ---
|
|
2180
|
+
pi.registerTool({
|
|
2181
|
+
name: "memory_curator_enable",
|
|
2182
|
+
label: "Memory Curator Enable",
|
|
2183
|
+
description: "Enable the external daily memory curator service. Uses a systemd user timer when available, with cron fallback, so curation can run even when pi is closed.",
|
|
2184
|
+
parameters: Type.Object({
|
|
2185
|
+
schedule: Type.Optional(Type.String({ description: "Daily schedule as HH:MM. Default: 03:00." })),
|
|
2186
|
+
}),
|
|
2187
|
+
async execute(_toolCallId, params) {
|
|
2188
|
+
const result = enableCuratorService({ memoryDir: MEMORY_DIR, cliPath: new URL("./src/cli.ts", import.meta.url).pathname, schedule: params.schedule });
|
|
2189
|
+
return { content: [{ type: "text", text: result.message }], details: result, isError: !result.ok };
|
|
2190
|
+
},
|
|
2191
|
+
});
|
|
2192
|
+
|
|
2193
|
+
pi.registerTool({
|
|
2194
|
+
name: "memory_curator_disable",
|
|
2195
|
+
label: "Memory Curator Disable",
|
|
2196
|
+
description: "Disable and uninstall the external daily memory curator service.",
|
|
2197
|
+
parameters: Type.Object({}),
|
|
2198
|
+
async execute() {
|
|
2199
|
+
const result = disableCuratorService({ memoryDir: MEMORY_DIR, cliPath: new URL("./src/cli.ts", import.meta.url).pathname });
|
|
2200
|
+
return { content: [{ type: "text", text: result.message }], details: result, isError: !result.ok };
|
|
2201
|
+
},
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2204
|
+
pi.registerTool({
|
|
2205
|
+
name: "memory_curator_status",
|
|
2206
|
+
label: "Memory Curator Status",
|
|
2207
|
+
description: "Show whether the external daily memory curator service is enabled and which backend it uses.",
|
|
2208
|
+
parameters: Type.Object({}),
|
|
2209
|
+
async execute() {
|
|
2210
|
+
const result = getCuratorServiceStatus({ memoryDir: MEMORY_DIR, cliPath: new URL("./src/cli.ts", import.meta.url).pathname });
|
|
2211
|
+
return { content: [{ type: "text", text: result.message }], details: result, isError: !result.ok };
|
|
2212
|
+
},
|
|
2213
|
+
});
|
|
2214
|
+
|
|
2215
|
+
pi.registerCommand("memory-curator-enable", {
|
|
2216
|
+
description: "Enable external daily memory curator service",
|
|
2217
|
+
handler: async (args, ctx) => {
|
|
2218
|
+
const schedule = args.trim() || undefined;
|
|
2219
|
+
const result = enableCuratorService({ memoryDir: MEMORY_DIR, cliPath: new URL("./src/cli.ts", import.meta.url).pathname, schedule });
|
|
2220
|
+
ctx.ui.notify(result.message, result.ok ? "info" : "error");
|
|
2221
|
+
},
|
|
2222
|
+
});
|
|
2223
|
+
|
|
2224
|
+
pi.registerCommand("memory-curator-disable", {
|
|
2225
|
+
description: "Disable external daily memory curator service",
|
|
2226
|
+
handler: async (_args, ctx) => {
|
|
2227
|
+
const result = disableCuratorService({ memoryDir: MEMORY_DIR, cliPath: new URL("./src/cli.ts", import.meta.url).pathname });
|
|
2228
|
+
ctx.ui.notify(result.message, result.ok ? "info" : "error");
|
|
2229
|
+
},
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
pi.registerCommand("memory-curator-status", {
|
|
2233
|
+
description: "Show external daily memory curator service status",
|
|
2234
|
+
handler: async (_args, ctx) => {
|
|
2235
|
+
const result = getCuratorServiceStatus({ memoryDir: MEMORY_DIR, cliPath: new URL("./src/cli.ts", import.meta.url).pathname });
|
|
2236
|
+
ctx.ui.notify(result.message, result.ok ? "info" : "error");
|
|
2237
|
+
},
|
|
2238
|
+
});
|
|
2239
|
+
|
|
2240
|
+
// --- memory_search tool ---
|
|
2241
|
+
pi.registerTool({
|
|
2242
|
+
name: "memory_search",
|
|
2243
|
+
label: "Memory Search",
|
|
2244
|
+
description:
|
|
2245
|
+
"Search across all memory files (MEMORY.md, SCRATCHPAD.md, daily logs).\n" +
|
|
2246
|
+
"Modes:\n" +
|
|
2247
|
+
"- 'keyword' (default, ~30ms): Fast BM25 search. Best for specific terms, dates, names, #tags, [[links]].\n" +
|
|
2248
|
+
"- 'semantic' (~2s): Meaning-based search. Finds related concepts even with different wording.\n" +
|
|
2249
|
+
"- 'deep' (~10s): Hybrid search with reranking. Use when other modes don't find what you need.\n" +
|
|
2250
|
+
"If semantic/deep warns about missing embeddings, run `qmd embed` once and retry.\n" +
|
|
2251
|
+
"If the first search doesn't find what you need, try rephrasing or switching modes. " +
|
|
2252
|
+
"Keyword mode is best for specific terms; semantic mode finds related concepts even with different wording.",
|
|
2253
|
+
parameters: Type.Object({
|
|
2254
|
+
query: Type.String({ description: "Search query" }),
|
|
2255
|
+
mode: Type.Optional(
|
|
2256
|
+
StringEnum(["keyword", "semantic", "deep"] as const, {
|
|
2257
|
+
description: "Search mode. Default: 'keyword'.",
|
|
2258
|
+
}),
|
|
2259
|
+
),
|
|
2260
|
+
limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
|
|
2261
|
+
}),
|
|
2262
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
2263
|
+
if (!qmdAvailable) {
|
|
2264
|
+
// Re-check on demand in case qmd was installed after session start.
|
|
2265
|
+
qmdAvailable = await detectQmd();
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
if (!qmdAvailable) {
|
|
2269
|
+
return {
|
|
2270
|
+
content: [
|
|
2271
|
+
{
|
|
2272
|
+
type: "text",
|
|
2273
|
+
text: qmdInstallInstructions(),
|
|
2274
|
+
},
|
|
2275
|
+
],
|
|
2276
|
+
isError: true,
|
|
2277
|
+
details: {},
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
let hasCollection = await checkCollection("pi-memory");
|
|
2282
|
+
if (!hasCollection) {
|
|
2283
|
+
const created = await setupQmdCollection();
|
|
2284
|
+
if (created) {
|
|
2285
|
+
hasCollection = true;
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
if (!hasCollection) {
|
|
2289
|
+
return {
|
|
2290
|
+
content: [
|
|
2291
|
+
{
|
|
2292
|
+
type: "text",
|
|
2293
|
+
text: "Could not set up qmd pi-memory collection. Check that qmd is working and the memory directory exists.",
|
|
2294
|
+
},
|
|
2295
|
+
],
|
|
2296
|
+
isError: true,
|
|
2297
|
+
details: {},
|
|
2298
|
+
};
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
const mode = params.mode ?? "keyword";
|
|
2302
|
+
const limit = params.limit ?? 5;
|
|
2303
|
+
|
|
2304
|
+
try {
|
|
2305
|
+
const { results, stderr } = await runQmdSearch(mode, params.query, limit);
|
|
2306
|
+
const needsEmbed = /need embeddings/i.test(stderr ?? "");
|
|
2307
|
+
|
|
2308
|
+
if (results.length === 0) {
|
|
2309
|
+
if (needsEmbed && (mode === "semantic" || mode === "deep")) {
|
|
2310
|
+
return {
|
|
2311
|
+
content: [
|
|
2312
|
+
{
|
|
2313
|
+
type: "text",
|
|
2314
|
+
text: [
|
|
2315
|
+
`No results found for "${params.query}" (mode: ${mode}).`,
|
|
2316
|
+
"",
|
|
2317
|
+
"qmd reports missing vector embeddings for one or more documents.",
|
|
2318
|
+
"Run this once, then retry:",
|
|
2319
|
+
" qmd embed",
|
|
2320
|
+
].join("\n"),
|
|
2321
|
+
},
|
|
2322
|
+
],
|
|
2323
|
+
details: { mode, query: params.query, count: 0, needsEmbed: true },
|
|
2324
|
+
};
|
|
2325
|
+
}
|
|
2326
|
+
return {
|
|
2327
|
+
content: [
|
|
2328
|
+
{
|
|
2329
|
+
type: "text",
|
|
2330
|
+
text: `No results found for "${params.query}" (mode: ${mode}).`,
|
|
2331
|
+
},
|
|
2332
|
+
],
|
|
2333
|
+
details: { mode, query: params.query, count: 0, needsEmbed },
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
const formatted = results
|
|
2338
|
+
.map((r, i) => {
|
|
2339
|
+
const parts: string[] = [`### Result ${i + 1}`];
|
|
2340
|
+
const filePath = getQmdResultPath(r);
|
|
2341
|
+
if (filePath) parts.push(`**File:** ${filePath}`);
|
|
2342
|
+
if (r.score != null) parts.push(`**Score:** ${r.score}`);
|
|
2343
|
+
const text = getQmdResultText(r);
|
|
2344
|
+
if (text) parts.push(`\n${text}`);
|
|
2345
|
+
return parts.join("\n");
|
|
2346
|
+
})
|
|
2347
|
+
.join("\n\n---\n\n");
|
|
2348
|
+
|
|
2349
|
+
return {
|
|
2350
|
+
content: [{ type: "text", text: formatted }],
|
|
2351
|
+
details: { mode, query: params.query, count: results.length, needsEmbed },
|
|
2352
|
+
};
|
|
2353
|
+
} catch (err) {
|
|
2354
|
+
return {
|
|
2355
|
+
content: [
|
|
2356
|
+
{
|
|
2357
|
+
type: "text",
|
|
2358
|
+
text: `memory_search error: ${err instanceof Error ? err.message : String(err)}`,
|
|
2359
|
+
},
|
|
2360
|
+
],
|
|
2361
|
+
isError: true,
|
|
2362
|
+
details: {},
|
|
2363
|
+
};
|
|
2364
|
+
}
|
|
2365
|
+
},
|
|
2366
|
+
});
|
|
2367
|
+
}
|