@seangle/pi-remember 0.2.0 → 0.3.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/CHANGELOG.md +41 -9
- package/README.md +149 -223
- package/dist/command.js +56 -0
- package/dist/context.js +57 -0
- package/dist/index.js +124 -0
- package/dist/tools.js +552 -0
- package/package.json +23 -24
- package/command.ts +0 -36
- package/index.ts +0 -751
- package/inject.ts +0 -45
- package/migration.ts +0 -458
- package/notes.ts +0 -131
- package/search.ts +0 -250
- package/store.ts +0 -730
- package/summary.ts +0 -70
- package/workspace.ts +0 -158
package/dist/index.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { ensureStoreVersion, readStoreVersion } from "@seangle/remember-core";
|
|
5
|
+
import { migrate } from "@seangle/remember-migrators";
|
|
6
|
+
import { registerMemoryCommand } from "./command.js";
|
|
7
|
+
import { loadConfig, storesFor, } from "./context.js";
|
|
8
|
+
import { detectForWorkspace, formatReport, snapshotFor, statusText, toolSpecs } from "./tools.js";
|
|
9
|
+
|
|
10
|
+
/** System-prompt section name; Pi wraps it in a matching <remember> tag. */
|
|
11
|
+
const SNAPSHOT_SECTION = "remember";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Offer a migration once per workspace and session when a legacy store exists
|
|
15
|
+
* and the target is not initialized. The model asks the user; nothing is
|
|
16
|
+
* written without consent.
|
|
17
|
+
*/
|
|
18
|
+
async function migrationNotice(cwd , config ) {
|
|
19
|
+
try {
|
|
20
|
+
const detection = await detectForWorkspace(cwd, config);
|
|
21
|
+
if (!detection) return "";
|
|
22
|
+
const targetVersion = await readStoreVersion({ dir: detection.target });
|
|
23
|
+
if (targetVersion !== null) return "";
|
|
24
|
+
const where = detection.source === detection.target
|
|
25
|
+
? `at ${detection.source}, which is missing only remember.json`
|
|
26
|
+
: `at ${detection.source}; no remember store exists at ${detection.target}`;
|
|
27
|
+
return `<remember-migration>\nLegacy memory detected: ${detection.description} ${where}.\nAsk the user whether to migrate. If they agree, call memory_migrate (dryRun first if unsure). If they decline, continue without it and do not ask again this session.\n</remember-migration>`;
|
|
28
|
+
} catch {
|
|
29
|
+
// Detection is advisory; never block injection on it.
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Mount the memory engine: ten model-facing tools, a `/memory` command, and one
|
|
36
|
+
* system-prompt section rebuilt at each run start. Pi patches the section only
|
|
37
|
+
* when its bytes change, so the snapshot stays cache-stable between runs.
|
|
38
|
+
*/
|
|
39
|
+
export default function (pi ) {
|
|
40
|
+
const config = loadConfig();
|
|
41
|
+
let noticeWorkspace = null;
|
|
42
|
+
|
|
43
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
44
|
+
noticeWorkspace = null;
|
|
45
|
+
// Adopt a store that already exists without a marker (e.g. a copied tree),
|
|
46
|
+
// and surface an invalid version instead of injecting nothing silently.
|
|
47
|
+
const stores = storesFor(ctx.cwd, config);
|
|
48
|
+
for (const scope of ["global", "local"] ) {
|
|
49
|
+
try {
|
|
50
|
+
await withFileMutationQueue(stores[scope].memoryFile, () => ensureStoreVersion(stores[scope]));
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (ctx.hasUI) ctx.ui.notify(`pi-remember: ${scope} memory unavailable: ${String(error)}`, "warning");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
58
|
+
if (!config.autoInject) {
|
|
59
|
+
delete event.systemPromptOptions.sections[SNAPSHOT_SECTION];
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const stores = storesFor(ctx.cwd, config);
|
|
63
|
+
const snapshot = await snapshotFor(stores, config, basename(ctx.cwd));
|
|
64
|
+
|
|
65
|
+
let notice = "";
|
|
66
|
+
if (noticeWorkspace !== ctx.cwd) {
|
|
67
|
+
notice = await migrationNotice(ctx.cwd, config);
|
|
68
|
+
if (notice) noticeWorkspace = ctx.cwd;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const text = [notice, snapshot.text].filter((part) => part.trim().length > 0).join("\n\n");
|
|
72
|
+
if (text) event.systemPromptOptions.sections[SNAPSHOT_SECTION] = text;
|
|
73
|
+
else delete event.systemPromptOptions.sections[SNAPSHOT_SECTION];
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
for (const spec of toolSpecs({ config })) {
|
|
77
|
+
pi.registerTool({
|
|
78
|
+
name: spec.name,
|
|
79
|
+
label: spec.label,
|
|
80
|
+
description: spec.description,
|
|
81
|
+
promptSnippet: spec.promptSnippet,
|
|
82
|
+
promptGuidelines: spec.promptGuidelines,
|
|
83
|
+
parameters: spec.parameters,
|
|
84
|
+
async execute(
|
|
85
|
+
_toolCallId ,
|
|
86
|
+
params ,
|
|
87
|
+
signal ,
|
|
88
|
+
_onUpdate ,
|
|
89
|
+
toolCtx ,
|
|
90
|
+
) {
|
|
91
|
+
try {
|
|
92
|
+
const result = await (spec.execute )(
|
|
93
|
+
params, { cwd: toolCtx.cwd, signal },
|
|
94
|
+
);
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: "text" , text: result.text }],
|
|
97
|
+
details: {},
|
|
98
|
+
...(result.isError ? { isError: true } : {}),
|
|
99
|
+
};
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: "text" , text: `remember: ${(error ).message}` }],
|
|
103
|
+
details: {},
|
|
104
|
+
isError: true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
registerMemoryCommand(pi, {
|
|
112
|
+
status: async (ctx) => statusText(storesFor(ctx.cwd, config), config, ctx.cwd),
|
|
113
|
+
migrate: async (ctx) => {
|
|
114
|
+
const detection = await detectForWorkspace(ctx.cwd, config);
|
|
115
|
+
if (!detection) return `No legacy memory store found for ${ctx.cwd}.`;
|
|
116
|
+
try {
|
|
117
|
+
const report = await migrate({ source: detection.source, target: detection.target });
|
|
118
|
+
return formatReport(report);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return `Migration refused: ${(error ).message}`;
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
5
|
+
import { truncateTail, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
appendEntry,
|
|
8
|
+
assertCurrentStoreVersion,
|
|
9
|
+
buildSnapshot,
|
|
10
|
+
entryId,
|
|
11
|
+
ensureStoreVersion,
|
|
12
|
+
findSimilar,
|
|
13
|
+
forgetEntries,
|
|
14
|
+
initializeMemory,
|
|
15
|
+
parseEntries,
|
|
16
|
+
readCatalogue,
|
|
17
|
+
readMemo,
|
|
18
|
+
resolveNotePath,
|
|
19
|
+
resolveWorkspaceStores,
|
|
20
|
+
restoreEntry,
|
|
21
|
+
searchStores,
|
|
22
|
+
statMemory,
|
|
23
|
+
updateMemo,
|
|
24
|
+
validNoteSlug,
|
|
25
|
+
workspaceAccessError,
|
|
26
|
+
writeNote,
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
} from "@seangle/remember-core";
|
|
32
|
+
import { detectMigrations, migrate, } from "@seangle/remember-migrators";
|
|
33
|
+
import { Type } from "typebox";
|
|
34
|
+
import { expandHome, legacySource, storesFor, } from "./context.js";
|
|
35
|
+
|
|
36
|
+
const READ_CAP = { maxBytes: 20_000, maxLines: 400 };
|
|
37
|
+
const NOTE_CAP = { maxBytes: 30_000, maxLines: 600 };
|
|
38
|
+
const MAX_FACT = 300;
|
|
39
|
+
const MAX_DESCRIPTION = 300;
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
const ROOT_PARAMETER = Type.Optional(Type.String({
|
|
58
|
+
minLength: 1,
|
|
59
|
+
description: "Workspace directory for local memory (not its .agents directory). Defaults to the session working directory; accepts absolute, relative-to-cwd, and ~/ paths. Ignored for global-only operations.",
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
const SCOPE_WRITE = Type.Optional(StringEnum(["local", "global"] , {
|
|
63
|
+
description: "local = this workspace; global = user/machine facts. Defaults to local.",
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
const SCOPE_READ = Type.Optional(StringEnum(["local", "global", "both"] , {
|
|
67
|
+
description: "Which stores to read. Defaults to both.",
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
const WORKSPACE_GUIDANCE =
|
|
71
|
+
" Optional root selects another workspace's local memory; global memory is unchanged. Local memory requires memory_init first.";
|
|
72
|
+
|
|
73
|
+
function resolveScopes(param ) {
|
|
74
|
+
if (param === "global") return ["global"];
|
|
75
|
+
if (param === "local") return ["local"];
|
|
76
|
+
return ["global", "local"];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatTruncated(t ) {
|
|
80
|
+
return t.truncated ? `${t.content}\n[truncated: showing ${t.outputLines}/${t.totalLines} lines]` : t.content;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatEntry(entry ) {
|
|
84
|
+
const id = entryId(entry);
|
|
85
|
+
return `${id ? `[id=${id}] ` : ""}${entry.line}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function restoreHint(recoveryId , scope , root ) {
|
|
89
|
+
const args = { recoveryId, scope, ...(scope === "local" && root !== undefined ? { root } : {}) };
|
|
90
|
+
return `Undo with memory_restore(${JSON.stringify(args)}).`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function failure(message ) {
|
|
94
|
+
return { text: message, isError: true };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Find a legacy store this workspace could migrate. Prefers `.pi/memory`, and
|
|
99
|
+
* also recognizes an in-place store (a copied tree missing only the marker).
|
|
100
|
+
*/
|
|
101
|
+
export async function detectForWorkspace(cwd , config ) {
|
|
102
|
+
const target = join(cwd, config.dirName, "memory");
|
|
103
|
+
const source = legacySource(cwd);
|
|
104
|
+
const candidates = await detectMigrations(source, target);
|
|
105
|
+
if (candidates.length > 0) return candidates[0];
|
|
106
|
+
const inPlace = await detectMigrations(target, target);
|
|
107
|
+
return inPlace[0] ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatReport(report ) {
|
|
111
|
+
const lines = [
|
|
112
|
+
`${report.dryRun ? "Dry run" : "Migrated"} ${report.plan?.inPlace ? "in place" : `from ${report.source} to ${report.target}`}: ${report.facts} fact(s), ${report.notes} note(s).`,
|
|
113
|
+
];
|
|
114
|
+
if (report.wrote.length > 0) lines.push(`Wrote: ${report.wrote.join(", ")}`);
|
|
115
|
+
if (report.skipped.length > 0) lines.push(`Skipped:\n${report.skipped.map((item) => `- ${item.rel}: ${item.reason}`).join("\n")}`);
|
|
116
|
+
if (report.warnings.length > 0) lines.push(`Warnings:\n${report.warnings.map((warning) => `- ${warning}`).join("\n")}`);
|
|
117
|
+
return lines.join("\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function statusText(stores , config , cwd ) {
|
|
121
|
+
const lines = [];
|
|
122
|
+
for (const scope of ["global", "local"] ) {
|
|
123
|
+
const stat = await statMemory(stores[scope]);
|
|
124
|
+
lines.push(stat.exists
|
|
125
|
+
? `${scope}: ${stat.path} — ${stat.entries} facts, ${stat.notesCount} notes, ${stat.bytes} bytes, ${stat.recoveryCount} recovery file(s)`
|
|
126
|
+
: `${scope}: ${stat.path} — no memory yet${scope === "local" ? "; run memory_init first" : ""}`);
|
|
127
|
+
}
|
|
128
|
+
const snapshot = await snapshotFor(stores, config, basename(cwd));
|
|
129
|
+
lines.push(`injected snapshot (current session: ${cwd}): ${snapshot.text.length} chars (global ${snapshot.globalChars}, local ${snapshot.localChars})`);
|
|
130
|
+
lines.push(`config: dirName=${config.dirName}, autoInject=${config.autoInject ? "on" : "off"}, snapshot budgets global/local ${config.injectCharsGlobal}/${config.injectCharsLocal}`);
|
|
131
|
+
return lines.join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Build the snapshot from both stores; never throws on a missing or invalid store. */
|
|
135
|
+
export async function snapshotFor(
|
|
136
|
+
stores ,
|
|
137
|
+
config ,
|
|
138
|
+
projectName ,
|
|
139
|
+
) {
|
|
140
|
+
const read = async (store ) => {
|
|
141
|
+
try {
|
|
142
|
+
await assertCurrentStoreVersion(store);
|
|
143
|
+
return await readCatalogue(store);
|
|
144
|
+
} catch {
|
|
145
|
+
return "";
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const [globalText, localText] = await Promise.all([read(stores.global), read(stores.local)]);
|
|
149
|
+
return buildSnapshot(globalText, localText, projectName, {
|
|
150
|
+
maxGlobalChars: config.injectCharsGlobal,
|
|
151
|
+
maxLocalChars: config.injectCharsLocal,
|
|
152
|
+
// Pi wraps a named system-prompt section in <remember> itself.
|
|
153
|
+
wrap: false,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Map the model-facing tools onto the host-agnostic engine. */
|
|
158
|
+
export function toolSpecs(ctx ) {
|
|
159
|
+
const queue = (path, operation) => withFileMutationQueue(path, operation);
|
|
160
|
+
const memoryDir = join("<workspace>", ctx.config.dirName, "memory");
|
|
161
|
+
|
|
162
|
+
/** Resolve stores for one call without changing the session's injected memory. */
|
|
163
|
+
const targetStores = (root , scopes , exec ) =>
|
|
164
|
+
scopes.includes("local")
|
|
165
|
+
? resolveWorkspaceStores(exec.cwd, ctx.config.globalDir, ctx.config.dirName, root)
|
|
166
|
+
: { status: "resolved" , root: exec.cwd, stores: storesFor(exec.cwd, ctx.config) };
|
|
167
|
+
|
|
168
|
+
/** Validate after acquiring the queue, mark a new store, then run the mutation. */
|
|
169
|
+
const mutate = async (
|
|
170
|
+
stores ,
|
|
171
|
+
scope ,
|
|
172
|
+
operation ,
|
|
173
|
+
requireInit = true,
|
|
174
|
+
) => {
|
|
175
|
+
return queue(stores[scope].memoryFile, async () => {
|
|
176
|
+
const error = workspaceAccessError(stores, [scope], requireInit);
|
|
177
|
+
if (error) return { status: "error" , message: error };
|
|
178
|
+
try {
|
|
179
|
+
// Mark a brand-new store before the first write, otherwise the
|
|
180
|
+
// post-write check sees content without a marker and refuses it.
|
|
181
|
+
await ensureStoreVersion(stores[scope], { create: true });
|
|
182
|
+
} catch (cause) {
|
|
183
|
+
return { status: "error" , message: String(cause) };
|
|
184
|
+
}
|
|
185
|
+
const value = await operation();
|
|
186
|
+
await ensureStoreVersion(stores[scope]);
|
|
187
|
+
return { status: "ok" , value };
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** Reads of other workspaces validate and migrate lazily; missing stores stay missing. */
|
|
192
|
+
const prepareReads = async (stores , scopes ) => {
|
|
193
|
+
for (const scope of scopes) {
|
|
194
|
+
const error = workspaceAccessError(stores, [scope]);
|
|
195
|
+
if (error) return error;
|
|
196
|
+
try {
|
|
197
|
+
await queue(stores[scope].memoryFile, () => assertCurrentStoreVersion(stores[scope]));
|
|
198
|
+
} catch (cause) {
|
|
199
|
+
return String(cause);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** Similar entries in the other scope of this call, not another workspace. */
|
|
206
|
+
const similarInOtherScope = async (stores , store , content ) => {
|
|
207
|
+
const other = store.scope === "local" ? stores.global : stores.local;
|
|
208
|
+
try {
|
|
209
|
+
return findSimilar(parseEntries(await readCatalogue(other)), content);
|
|
210
|
+
} catch {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const similarWarning = (similar , scope ) =>
|
|
216
|
+
similar.length > 0
|
|
217
|
+
? `\nNote: similar entry in ${scope === "local" ? "global" : "local"} memory: ${similar[0].line}\nKeep knowledge in ONE scope — memory_forget the copy that does not belong.`
|
|
218
|
+
: "";
|
|
219
|
+
|
|
220
|
+
return [
|
|
221
|
+
{
|
|
222
|
+
name: "memory_init",
|
|
223
|
+
label: "Memory Init",
|
|
224
|
+
description:
|
|
225
|
+
`Explicitly initialize local memory in an existing workspace. Optional root defaults to the session working directory. Creates ${memoryDir}/MEMORY.md only if missing; never overwrites existing memory or creates the workspace itself. Global memory needs no initialization.`,
|
|
226
|
+
promptSnippet: "Initialize local memory in an existing workspace",
|
|
227
|
+
promptGuidelines: [
|
|
228
|
+
"Use root to access local memory in another workspace. Initialize it with memory_init only when you intend to create a memory store there; do not initialize merely to make a search succeed.",
|
|
229
|
+
],
|
|
230
|
+
parameters: Type.Object({ root: ROOT_PARAMETER }),
|
|
231
|
+
async execute(args , exec ) {
|
|
232
|
+
const target = targetStores(args.root, ["local"], exec);
|
|
233
|
+
if (target.status === "error") return failure(target.message);
|
|
234
|
+
const store = target.stores.local;
|
|
235
|
+
const mutation = await mutate(target.stores, "local", () => initializeMemory(store), false);
|
|
236
|
+
if (mutation.status === "error") return failure(mutation.message);
|
|
237
|
+
const result = mutation.value;
|
|
238
|
+
if (result.status === "error") return failure(result.message);
|
|
239
|
+
return {
|
|
240
|
+
text: result.status === "initialized"
|
|
241
|
+
? `Initialized local memory: ${store.memoryFile}`
|
|
242
|
+
: `Local memory already initialized: ${store.memoryFile}. Existing content preserved.`,
|
|
243
|
+
};
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: "memory_write",
|
|
248
|
+
label: "Memory Write",
|
|
249
|
+
description:
|
|
250
|
+
`Append exactly ONE concise, durable fact to memory. Default scope 'local' (this workspace, ${ctx.config.dirName}/memory/MEMORY.md). ` +
|
|
251
|
+
"Use scope 'global' ONLY for machine/user-wide facts (identity, OS, global tool preferences). " +
|
|
252
|
+
"Store only knowledge that was expensive to obtain and will plausibly matter in a FUTURE session: stable preferences, decisions, lessons, environment quirks. " +
|
|
253
|
+
"NEVER store temporary session state: current task, todos, in-progress work, debugging state, 'user asked me to X'. " +
|
|
254
|
+
"One line only, max 300 chars. For longer content use memory_store, which writes a standalone note." +
|
|
255
|
+
WORKSPACE_GUIDANCE,
|
|
256
|
+
promptSnippet: "Persist one durable fact to memory (project-local or global)",
|
|
257
|
+
promptGuidelines: [
|
|
258
|
+
"Use memory_write sparingly — only for facts a future session will plausibly need. When in doubt, don't write.",
|
|
259
|
+
"global scope is reserved for facts about the user or the machine; facts about specific software, repos, or configs stay local to the workdir where they were learned.",
|
|
260
|
+
"Do not record session state (what you're doing now, todos, next steps) — pi's own session files persist that.",
|
|
261
|
+
"Keep MEMORY.md for one-line facts only. Notes carry their own metadata; their descriptions are automatically included in session context.",
|
|
262
|
+
"Use memory_search to recall stored knowledge before re-deriving it or asking the user.",
|
|
263
|
+
],
|
|
264
|
+
parameters: Type.Object({
|
|
265
|
+
content: Type.String({ description: "Single fact to remember (one line, max 300 chars)." }),
|
|
266
|
+
scope: SCOPE_WRITE,
|
|
267
|
+
root: ROOT_PARAMETER,
|
|
268
|
+
}),
|
|
269
|
+
async execute(args , exec ) {
|
|
270
|
+
if (args.content.length > MAX_FACT) {
|
|
271
|
+
return failure(`Entry too long for the index (${args.content.length} chars, max ${MAX_FACT}). Use memory_store for detailed knowledge — it writes a notes file and keeps MEMORY.md lean.`);
|
|
272
|
+
}
|
|
273
|
+
const scope = args.scope ?? "local";
|
|
274
|
+
const target = targetStores(args.root, [scope], exec);
|
|
275
|
+
if (target.status === "error") return failure(target.message);
|
|
276
|
+
const store = target.stores[scope];
|
|
277
|
+
const mutation = await mutate(target.stores, scope, () => appendEntry(store, args.content));
|
|
278
|
+
if (mutation.status === "error") return failure(mutation.message);
|
|
279
|
+
const result = mutation.value;
|
|
280
|
+
if (result.duplicate) {
|
|
281
|
+
return { text: `Duplicate — already in ${scope} memory:\n${(result.existing ?? []).map((entry) => `- ${formatEntry(entry)}`).join("\n")}` };
|
|
282
|
+
}
|
|
283
|
+
if (!result.line) return { text: "Empty content — nothing saved." };
|
|
284
|
+
const similar = await similarInOtherScope(target.stores, store, result.line);
|
|
285
|
+
return { text: `Saved to ${scope} memory (${store.memoryFile}). Memo ID: ${result.id}.${similarWarning(similar, scope)}` };
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: "memory_store",
|
|
290
|
+
label: "Memory Store Note",
|
|
291
|
+
description:
|
|
292
|
+
"Store detailed knowledge as a standalone Markdown note with description, ID and timestamps in frontmatter. " +
|
|
293
|
+
"Only note descriptions/IDs/paths enter context; bodies are read on demand via memory_read. " +
|
|
294
|
+
"Filenames always prepend the UTC date; no Markdown heading is added. Default scope 'local'. " +
|
|
295
|
+
"Existing/duplicate entries are returned without creating or overwriting a note; use memory_update to revise an indexed memo." +
|
|
296
|
+
WORKSPACE_GUIDANCE,
|
|
297
|
+
promptSnippet: "Store detailed knowledge as a dated, self-describing note",
|
|
298
|
+
promptGuidelines: [
|
|
299
|
+
"Use memory_store instead of memory_write whenever knowledge needs more than one line. MEMORY.md holds only short facts; notes are discovered from frontmatter.",
|
|
300
|
+
"When recalling, prefer memory_search to find notes, then memory_read(path=...) to fetch the full note.",
|
|
301
|
+
],
|
|
302
|
+
parameters: Type.Object({
|
|
303
|
+
title: Type.String({ description: "Short note description stored in frontmatter and injected into context (max 300 chars). Not a heading." }),
|
|
304
|
+
content: Type.String({ description: "Markdown body only; no frontmatter. Include your own heading if useful." }),
|
|
305
|
+
slug: Type.Optional(Type.String({
|
|
306
|
+
minLength: 1,
|
|
307
|
+
maxLength: 80,
|
|
308
|
+
description: "Concise filename slug: letters/digits separated by hyphens, no date/path/extension. UTC date is prepended.",
|
|
309
|
+
})),
|
|
310
|
+
scope: SCOPE_WRITE,
|
|
311
|
+
root: ROOT_PARAMETER,
|
|
312
|
+
}),
|
|
313
|
+
async execute(args , exec ) {
|
|
314
|
+
if (!args.title.trim() || /[\x00-\x1f\x7f]/.test(args.title)) return failure("Description must be a nonempty single line.");
|
|
315
|
+
if (!args.content.trim()) return failure("Empty note body — nothing saved.");
|
|
316
|
+
if (args.slug !== undefined && !validNoteSlug(args.slug)) return failure("Invalid slug: use 1–80 letters/digits separated by hyphens, without date, path or extension.");
|
|
317
|
+
if (args.title.length > MAX_DESCRIPTION) return failure(`Title too long (${args.title.length} chars, max ${MAX_DESCRIPTION}).`);
|
|
318
|
+
const scope = args.scope ?? "local";
|
|
319
|
+
const target = targetStores(args.root, [scope], exec);
|
|
320
|
+
if (target.status === "error") return failure(target.message);
|
|
321
|
+
const store = target.stores[scope];
|
|
322
|
+
const mutation = await mutate(target.stores, scope, () => writeNote(store, args.title, args.content, args.slug));
|
|
323
|
+
if (mutation.status === "error") return failure(mutation.message);
|
|
324
|
+
const result = mutation.value;
|
|
325
|
+
if (result.duplicate) {
|
|
326
|
+
return { text: `Already indexed in ${scope} memory; no note created or overwritten:\n${(result.existing ?? []).map((entry) => `- ${formatEntry(entry)}`).join("\n")}\nRead the existing memo and use memory_update to revise it.` };
|
|
327
|
+
}
|
|
328
|
+
const similar = await similarInOtherScope(target.stores, store, result.indexLine);
|
|
329
|
+
return {
|
|
330
|
+
text: `Stored note: ${result.noteRel}\nMemory file: ${store.memoryFile}\nMemo ID: ${result.id}\nDescription: ${result.indexLine}\nMetadata is stored in the note, not duplicated in MEMORY.md.${similarWarning(similar, scope)}`,
|
|
331
|
+
};
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
name: "memory_update",
|
|
336
|
+
label: "Memory Update",
|
|
337
|
+
description:
|
|
338
|
+
"Update ONE existing memo by its exact eight-character metadata ID, preserving ID, creation date, note path and description. " +
|
|
339
|
+
"Default scope 'local'. Read the memo first. For facts, content replaces the fact (one line, max 300 chars); " +
|
|
340
|
+
"for notes, content replaces the full Markdown body excluding frontmatter. Blank content is rejected; identical content is a no-op. " +
|
|
341
|
+
"Actual changes return a recovery ID for memory_restore." +
|
|
342
|
+
WORKSPACE_GUIDANCE,
|
|
343
|
+
promptSnippet: "Revise one memo in place by its ID",
|
|
344
|
+
parameters: Type.Object({
|
|
345
|
+
id: Type.String({ pattern: "^[a-fA-F0-9]{8}$", description: "Exact memo ID from index metadata or a tool result (not a recovery ID)." }),
|
|
346
|
+
content: Type.String({ description: "Replacement fact or full Markdown note body; omit frontmatter." }),
|
|
347
|
+
scope: SCOPE_WRITE,
|
|
348
|
+
root: ROOT_PARAMETER,
|
|
349
|
+
}),
|
|
350
|
+
async execute(args , exec ) {
|
|
351
|
+
const scope = args.scope ?? "local";
|
|
352
|
+
const target = targetStores(args.root, [scope], exec);
|
|
353
|
+
if (target.status === "error") return failure(target.message);
|
|
354
|
+
const store = target.stores[scope];
|
|
355
|
+
const mutation = await mutate(target.stores, scope, () => updateMemo(store, args.id, args.content, queue));
|
|
356
|
+
if (mutation.status === "error") return failure(mutation.message);
|
|
357
|
+
const result = mutation.value;
|
|
358
|
+
if (result.status === "error") return failure(result.message);
|
|
359
|
+
return {
|
|
360
|
+
text: result.status === "unchanged"
|
|
361
|
+
? `Memo ${args.id} unchanged; nothing written.`
|
|
362
|
+
: `Updated memo ${args.id} in ${scope} memory (${store.dir}). ID, creation date and note reference preserved; note last-modified-date updated.\nRecovery ID: ${result.recoveryId}\n${restoreHint(result.recoveryId, scope, args.root)}`,
|
|
363
|
+
};
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
name: "memory_read",
|
|
368
|
+
label: "Memory Read",
|
|
369
|
+
description:
|
|
370
|
+
"Read memory contents. Default: one-line facts plus a derived note catalogue for scope 'both' (global + local). " +
|
|
371
|
+
"Optional query filters index entries (case-insensitive substring). Optional path reads an existing note file. " +
|
|
372
|
+
"Optional id retrieves exactly one indexed memo, including note contents. Do not combine id with query/path. " +
|
|
373
|
+
"Ambiguous IDs/paths require an explicit scope; no guessing." +
|
|
374
|
+
WORKSPACE_GUIDANCE,
|
|
375
|
+
promptSnippet: "Read memory facts, the note catalogue, or one memo by ID/path",
|
|
376
|
+
parameters: Type.Object({
|
|
377
|
+
id: Type.Optional(Type.String({ pattern: "^[a-fA-F0-9]{8}$", description: "Exact memo ID; returns the index entry and linked note contents." })),
|
|
378
|
+
query: Type.Optional(Type.String({ description: "Case-insensitive substring filter over catalogue lines." })),
|
|
379
|
+
path: Type.Optional(Type.String({ description: "Note path to read, e.g. 'notes/2026-08-27-foo.md'." })),
|
|
380
|
+
scope: SCOPE_READ,
|
|
381
|
+
root: ROOT_PARAMETER,
|
|
382
|
+
}),
|
|
383
|
+
async execute(args , exec ) {
|
|
384
|
+
const scopes = resolveScopes(args.scope);
|
|
385
|
+
const target = targetStores(args.root, scopes, exec);
|
|
386
|
+
if (target.status === "error") return failure(target.message);
|
|
387
|
+
const preparationError = await prepareReads(target.stores, scopes);
|
|
388
|
+
if (preparationError) return failure(preparationError);
|
|
389
|
+
|
|
390
|
+
if (args.id !== undefined) {
|
|
391
|
+
if (args.query !== undefined || args.path !== undefined) return failure("Specify id alone, or use query/path without id.");
|
|
392
|
+
const memo = await readMemo(target.stores, args.id, scopes);
|
|
393
|
+
if (memo.status === "error") return failure(memo.message);
|
|
394
|
+
const out = `## ${memo.store.scope} memo ${args.id} — ${memo.store.memoryFile}\n\n${memo.entry.meta ?? ""}\n${memo.entry.line}` +
|
|
395
|
+
(memo.noteText !== undefined ? `\n\n## Note: ${memo.noteRel}\n\n${memo.noteText}` : "");
|
|
396
|
+
return { text: formatTruncated(truncateTail(out, NOTE_CAP)) };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (args.path !== undefined) {
|
|
400
|
+
const resolved = resolveNotePath(target.stores, args.path, scopes);
|
|
401
|
+
if (resolved.status === "error") return failure(resolved.message);
|
|
402
|
+
const text = await readFile(resolved.absPath, "utf8");
|
|
403
|
+
return { text: formatTruncated(truncateTail(`## ${resolved.store.scope} note: ${resolved.rel}\n\n${text}`, NOTE_CAP)) };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const sections = [];
|
|
407
|
+
for (const scope of scopes) {
|
|
408
|
+
const store = target.stores[scope];
|
|
409
|
+
const text = await readCatalogue(store);
|
|
410
|
+
let body = text;
|
|
411
|
+
if (args.query) {
|
|
412
|
+
const q = args.query.toLowerCase();
|
|
413
|
+
body = parseEntries(text)
|
|
414
|
+
.filter((entry) => entry.line.toLowerCase().includes(q) || (entry.meta ?? "").toLowerCase().includes(q))
|
|
415
|
+
.map((entry) => `${entry.meta ? `${entry.meta}\n` : ""}${entry.line}`)
|
|
416
|
+
.join("\n");
|
|
417
|
+
}
|
|
418
|
+
const label = scope === "local" ? `local (${basename(target.root)})` : "global";
|
|
419
|
+
sections.push(`## ${label} memory — ${store.memoryFile}${existsSync(store.memoryFile) ? "" : " (missing)"}\n\n${body.trim() || "(empty)"}`);
|
|
420
|
+
}
|
|
421
|
+
return { text: formatTruncated(truncateTail(sections.join("\n\n"), READ_CAP)) };
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
name: "memory_search",
|
|
426
|
+
label: "Memory Search",
|
|
427
|
+
description:
|
|
428
|
+
"Search facts and note bodies with weighted lexical ranking, exact tokens, phrase/coverage boosts and matching-passage counts. Descriptions and tags weigh more; one result per note. Default scope 'both'." +
|
|
429
|
+
WORKSPACE_GUIDANCE,
|
|
430
|
+
promptSnippet: "Search memory facts and note bodies",
|
|
431
|
+
parameters: Type.Object({
|
|
432
|
+
query: Type.String({ description: "Search terms." }),
|
|
433
|
+
scope: SCOPE_READ,
|
|
434
|
+
root: ROOT_PARAMETER,
|
|
435
|
+
}),
|
|
436
|
+
async execute(args , exec ) {
|
|
437
|
+
const scopes = resolveScopes(args.scope);
|
|
438
|
+
const target = targetStores(args.root, scopes, exec);
|
|
439
|
+
if (target.status === "error") return failure(target.message);
|
|
440
|
+
const preparationError = await prepareReads(target.stores, scopes);
|
|
441
|
+
if (preparationError) return failure(preparationError);
|
|
442
|
+
const hits = await searchStores(target.stores, args.query, scopes, 20);
|
|
443
|
+
if (hits.length === 0) return { text: "No matches in memory." };
|
|
444
|
+
const lines = hits.map((hit) =>
|
|
445
|
+
`[${hit.scope}/${hit.file}] ${hit.id ? `[id=${hit.id}] ` : ""}${hit.line}${hit.matchLines > 1 ? ` (+${hit.matchLines - 1} more matching lines)` : ""}`,
|
|
446
|
+
);
|
|
447
|
+
return { text: formatTruncated(truncateTail(lines.join("\n"), READ_CAP)) };
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
name: "memory_forget",
|
|
452
|
+
label: "Memory Forget",
|
|
453
|
+
description:
|
|
454
|
+
"Delete facts or notes whose description, path or metadata contains the given text (case-insensitive); note bodies are not matched. Removes note files with recoverable snapshots. Returns a recovery ID for memory_restore. Default scope 'local'." +
|
|
455
|
+
WORKSPACE_GUIDANCE,
|
|
456
|
+
promptSnippet: "Forget facts or notes matching a string (recoverable)",
|
|
457
|
+
parameters: Type.Object({
|
|
458
|
+
text: Type.String({ description: "Text that identifies entries to delete." }),
|
|
459
|
+
scope: SCOPE_WRITE,
|
|
460
|
+
root: ROOT_PARAMETER,
|
|
461
|
+
}),
|
|
462
|
+
async execute(args , exec ) {
|
|
463
|
+
const scope = args.scope ?? "local";
|
|
464
|
+
const target = targetStores(args.root, [scope], exec);
|
|
465
|
+
if (target.status === "error") return failure(target.message);
|
|
466
|
+
const store = target.stores[scope];
|
|
467
|
+
const mutation = await mutate(target.stores, scope, () => forgetEntries(store, args.text, queue));
|
|
468
|
+
if (mutation.status === "error") return failure(mutation.message);
|
|
469
|
+
const result = mutation.value;
|
|
470
|
+
if (result.recoveryId === null) return { text: `No entries matching "${args.text}" in ${scope} memory.` };
|
|
471
|
+
return { text: `Removed ${result.removed.length} entr(y/ies) from ${scope} memory (${store.memoryFile}). Recovery id: ${result.recoveryId}\n${restoreHint(result.recoveryId, scope, args.root)}` };
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
name: "memory_restore",
|
|
476
|
+
label: "Memory Restore",
|
|
477
|
+
description:
|
|
478
|
+
"Restore entries deleted by memory_forget or undo a memory_update using its recovery ID. Refuses to overwrite later edits; conflicts retain the recovery record. Default scope 'both'; use the same root as the original change." +
|
|
479
|
+
WORKSPACE_GUIDANCE,
|
|
480
|
+
promptSnippet: "Undo a forget or update with its recovery ID",
|
|
481
|
+
parameters: Type.Object({
|
|
482
|
+
recoveryId: Type.String({ description: "Recovery id returned by memory_forget or memory_update." }),
|
|
483
|
+
scope: SCOPE_READ,
|
|
484
|
+
root: ROOT_PARAMETER,
|
|
485
|
+
}),
|
|
486
|
+
async execute(args , exec ) {
|
|
487
|
+
const scopes = resolveScopes(args.scope);
|
|
488
|
+
const target = targetStores(args.root, scopes, exec);
|
|
489
|
+
if (target.status === "error") return failure(target.message);
|
|
490
|
+
const result = await restoreEntry(
|
|
491
|
+
target.stores,
|
|
492
|
+
args.recoveryId,
|
|
493
|
+
queue,
|
|
494
|
+
scopes,
|
|
495
|
+
(store) => workspaceAccessError(target.stores, [store.scope]),
|
|
496
|
+
ensureStoreVersion,
|
|
497
|
+
);
|
|
498
|
+
if (!result) return { text: `No recovery record found for id ${args.recoveryId}.` };
|
|
499
|
+
if (result.status === "error") return failure(result.message);
|
|
500
|
+
return {
|
|
501
|
+
text: `Restored ${result.entries.length} entr(y/ies) to ${result.scope} memory:\n${result.entries.map((entry) => `- ${entry.line}`).join("\n")}`,
|
|
502
|
+
};
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
name: "memory_status",
|
|
507
|
+
label: "Memory Status",
|
|
508
|
+
description:
|
|
509
|
+
"Show memory file locations, sizes, entry counts, and current session's injected snapshot size. Optional root selects another existing workspace. Works before memory_init and never creates files.",
|
|
510
|
+
promptSnippet: "Show memory store locations, counts, and snapshot size",
|
|
511
|
+
parameters: Type.Object({ root: ROOT_PARAMETER }),
|
|
512
|
+
async execute(args , exec ) {
|
|
513
|
+
const target = targetStores(args.root, ["local"], exec);
|
|
514
|
+
if (target.status === "error") return failure(target.message);
|
|
515
|
+
return { text: await statusText(target.stores, ctx.config, target.root) };
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
name: "memory_migrate",
|
|
520
|
+
label: "Memory Migrate",
|
|
521
|
+
description:
|
|
522
|
+
"Migrate a legacy plain-file memory store into this workspace's shared store. Detects a pi-remember v1 store at <workspace>/.pi/memory (or an in-place copy missing only the marker). " +
|
|
523
|
+
"Copies current facts and notes only; recovery records, migration backups and other history are left behind. " +
|
|
524
|
+
"Never overwrites an initialized target. Use dryRun first when unsure, and ask the user before migrating.",
|
|
525
|
+
promptSnippet: "Migrate a legacy pi-remember store into this workspace",
|
|
526
|
+
parameters: Type.Object({
|
|
527
|
+
source: Type.Optional(Type.String({ description: "Legacy store directory. Defaults to <workspace>/.pi/memory." })),
|
|
528
|
+
target: Type.Optional(Type.String({ description: `Destination store directory. Defaults to <workspace>/${ctx.config.dirName}/memory.` })),
|
|
529
|
+
dryRun: Type.Optional(Type.Boolean({ description: "Plan and report without writing anything." })),
|
|
530
|
+
root: ROOT_PARAMETER,
|
|
531
|
+
}),
|
|
532
|
+
async execute(args , exec ) {
|
|
533
|
+
const cwd = args.root?.trim() ? resolve(exec.cwd, expandHome(args.root.trim())) : exec.cwd;
|
|
534
|
+
const explicit = Boolean(args.source?.trim() || args.target?.trim());
|
|
535
|
+
let source = args.source?.trim() ? resolve(cwd, expandHome(args.source.trim())) : legacySource(cwd);
|
|
536
|
+
let target = args.target?.trim() ? resolve(cwd, expandHome(args.target.trim())) : join(cwd, ctx.config.dirName, "memory");
|
|
537
|
+
if (!explicit) {
|
|
538
|
+
const detection = await detectForWorkspace(cwd, ctx.config);
|
|
539
|
+
if (!detection) return { text: `No legacy memory store found for ${cwd} (looked for ${legacySource(cwd)} and an in-place marker in ${target}).` };
|
|
540
|
+
source = detection.source;
|
|
541
|
+
target = detection.target;
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
const report = await migrate({ source, target, dryRun: args.dryRun === true });
|
|
545
|
+
return { text: formatReport(report) };
|
|
546
|
+
} catch (error) {
|
|
547
|
+
return failure(`Migration refused: ${(error ).message}`);
|
|
548
|
+
}
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
];
|
|
552
|
+
}
|