pi-memoria 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -586
- package/index.ts +38 -98
- package/package.json +1 -1
- package/skills/memoria/SKILL.md +14 -2
- package/src/commands.ts +8 -84
- package/src/config.ts +3 -11
- package/src/index-engine.ts +34 -7
- package/src/recall.ts +63 -38
- package/src/runtime.ts +79 -249
- package/src/sessions.ts +24 -3
- package/src/store.ts +102 -21
- package/src/tools.ts +10 -135
- package/src/types.ts +2 -10
- package/src/usage.ts +15 -1
- package/src/util.ts +5 -2
- package/src/learn.ts +0 -299
- package/src/transfer.ts +0 -180
package/src/sessions.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { spawn } from "node:child_process";
|
|
27
27
|
import { accessSync, constants, existsSync } from "node:fs";
|
|
28
|
-
import { readdir, readFile, stat } from "node:fs/promises";
|
|
28
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
29
29
|
import { basename, delimiter, join, relative, resolve, sep } from "node:path";
|
|
30
30
|
import { isCjkChar, stem, tokenize } from "./tokenize.ts";
|
|
31
31
|
import { oneLine, truncateChars } from "./util.ts";
|
|
@@ -765,8 +765,29 @@ export class SessionStore {
|
|
|
765
765
|
*/
|
|
766
766
|
async readWindow(path: string, line: number, windowSize = 6): Promise<SessionReadResult | undefined> {
|
|
767
767
|
const target = resolve(path);
|
|
768
|
-
|
|
769
|
-
|
|
768
|
+
if (!target.endsWith(".jsonl")) return undefined;
|
|
769
|
+
let realTarget: string;
|
|
770
|
+
let stats;
|
|
771
|
+
try {
|
|
772
|
+
[realTarget, stats] = await Promise.all([realpath(target), stat(target)]);
|
|
773
|
+
} catch {
|
|
774
|
+
return undefined;
|
|
775
|
+
}
|
|
776
|
+
if (!stats.isFile() || stats.size > MAX_FILE_BYTES) return undefined;
|
|
777
|
+
let root: string | undefined;
|
|
778
|
+
for (const candidate of this.roots) {
|
|
779
|
+
let realRoot: string;
|
|
780
|
+
try {
|
|
781
|
+
realRoot = await realpath(candidate);
|
|
782
|
+
} catch {
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
if (target.startsWith(`${resolve(candidate)}${sep}`) && realTarget.startsWith(`${realRoot}${sep}`)) {
|
|
786
|
+
root = candidate;
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (!root) return undefined;
|
|
770
791
|
if (!Number.isFinite(line) || line < 1) return undefined;
|
|
771
792
|
const size = Math.max(0, Math.min(40, Math.floor(windowSize)));
|
|
772
793
|
const from = Math.max(1, Math.floor(line) - size);
|
package/src/store.ts
CHANGED
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
* pure and testable.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import { mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
8
|
+
import { mkdir, readdir, readFile, realpath, rm, stat } from "node:fs/promises";
|
|
10
9
|
import { basename, join, relative, resolve } from "node:path";
|
|
11
10
|
import { parseDateValue, splitFrontmatter, stringifyWithFrontmatter } from "./frontmatter.ts";
|
|
12
11
|
import { accumulateTokens, stem, tokenize, tokenizeRaw } from "./tokenize.ts";
|
|
@@ -1090,8 +1089,8 @@ export interface MoveMemoryInput {
|
|
|
1090
1089
|
/** Target category folder. Defaults to the note's current category. */
|
|
1091
1090
|
category?: string;
|
|
1092
1091
|
/**
|
|
1093
|
-
* When the target topic already exists: true
|
|
1094
|
-
* it
|
|
1092
|
+
* When the target topic already exists: true explicitly merges this note into
|
|
1093
|
+
* it; omitted or false refuses and reports the conflict.
|
|
1095
1094
|
*/
|
|
1096
1095
|
merge?: boolean;
|
|
1097
1096
|
/** Record the note's previous names as aliases of the target (default true). */
|
|
@@ -1147,17 +1146,33 @@ export async function moveMemory(
|
|
|
1147
1146
|
}
|
|
1148
1147
|
|
|
1149
1148
|
const target = await readMemoryDoc(root, targetPath);
|
|
1150
|
-
if (target && input.merge
|
|
1149
|
+
if (target && input.merge !== true) {
|
|
1151
1150
|
throw new Error(
|
|
1152
1151
|
`${targetRelPath} already exists (topic "${target.topic}"), so moving ${doc.relPath} there would either overwrite or mix two subjects. Re-run with merge: true to combine them, or pick a different topic.`,
|
|
1153
1152
|
);
|
|
1154
1153
|
}
|
|
1155
1154
|
const previousAliases = target ? normalizeAliases([...target.aliases, ...parseStringList(target.frontmatter.aliases)]) : [];
|
|
1155
|
+
const historicalIds = normalizeAliases([...(target ? [doc.id] : []), ...previousAliases, ...doc.aliases].filter((alias) => alias.startsWith("mem_")));
|
|
1156
|
+
if (target && historicalIds.length >= MAX_ALIASES) {
|
|
1157
|
+
throw new Error(`Cannot merge ${doc.relPath}: the target has reached the ${MAX_ALIASES}-alias limit for historical ids.`);
|
|
1158
|
+
}
|
|
1156
1159
|
const aliases = normalizeAliases([
|
|
1160
|
+
...historicalIds,
|
|
1161
|
+
...(target ? [doc.id] : []),
|
|
1157
1162
|
...previousAliases,
|
|
1158
1163
|
...doc.aliases,
|
|
1159
1164
|
...(keepAlias ? [doc.topic, doc.title, basename(doc.relPath).replace(/\.md$/i, "")] : []),
|
|
1160
1165
|
]);
|
|
1166
|
+
const requiredAliases = [
|
|
1167
|
+
...(target ? [doc.id] : []), ...previousAliases, ...doc.aliases,
|
|
1168
|
+
...(keepAlias ? [doc.topic, doc.title, basename(doc.relPath).replace(/\.md$/i, "")] : []),
|
|
1169
|
+
];
|
|
1170
|
+
if (requiredAliases.some((alias) => {
|
|
1171
|
+
const normalized = normalizeAliases([alias])[0];
|
|
1172
|
+
return normalized && !aliases.some((saved) => saved.toLowerCase() === normalized.toLowerCase());
|
|
1173
|
+
})) {
|
|
1174
|
+
throw new Error(`Cannot move ${doc.relPath}: keeping its former names would exceed the ${MAX_ALIASES}-alias limit.`);
|
|
1175
|
+
}
|
|
1161
1176
|
const aliasesAdded = aliases.filter((alias) => !previousAliases.some((previous) => previous.toLowerCase() === alias.toLowerCase()));
|
|
1162
1177
|
|
|
1163
1178
|
if (target) {
|
|
@@ -1173,14 +1188,28 @@ export async function moveMemory(
|
|
|
1173
1188
|
}
|
|
1174
1189
|
const tags = [...new Set([...target.tags, ...doc.tags])];
|
|
1175
1190
|
const priority = priorityRank(doc.priority) > priorityRank(target.priority) ? doc.priority : target.priority;
|
|
1191
|
+
const related = normalizeLinks([...target.related, ...doc.related]);
|
|
1192
|
+
const supersedes = normalizeLinks([...target.supersedes, ...doc.supersedes]);
|
|
1193
|
+
const linksFit = (required: string[], saved: string[]): boolean => required.every((link) => {
|
|
1194
|
+
const normalized = normalizeLinks([link])[0];
|
|
1195
|
+
return !normalized || saved.some((entry) => entry.toLowerCase() === normalized.toLowerCase());
|
|
1196
|
+
});
|
|
1197
|
+
if (!linksFit([...target.related, ...doc.related], related) || !linksFit([...target.supersedes, ...doc.supersedes], supersedes)) {
|
|
1198
|
+
throw new Error(`Cannot merge ${doc.relPath}: keeping its links would exceed the ${MAX_LINKS}-link limit.`);
|
|
1199
|
+
}
|
|
1200
|
+
// The journal must exist before the target changes. Recovery only removes
|
|
1201
|
+
// the source after it verifies that the target contains all its facts and
|
|
1202
|
+
// its former id; an interrupted target write therefore cannot lose data.
|
|
1203
|
+
await writeJournal(root, { op: "move", from: doc.relPath, to: target.relPath, merge: true, at: Date.now() });
|
|
1176
1204
|
const updated = await updateMemory(root, target, {
|
|
1177
1205
|
content: body,
|
|
1178
1206
|
tags,
|
|
1179
1207
|
aliases,
|
|
1208
|
+
related,
|
|
1209
|
+
supersedes,
|
|
1180
1210
|
summary: extendSummary(target.summary, doc.summary),
|
|
1181
1211
|
priority,
|
|
1182
1212
|
});
|
|
1183
|
-
await writeJournal(root, { op: "move", from: doc.relPath, to: updated.relPath, at: Date.now() });
|
|
1184
1213
|
const trashPath = await deleteMemory(root, doc);
|
|
1185
1214
|
await clearJournal(root).catch(() => {});
|
|
1186
1215
|
return { ...base, doc: updated, moved: true, merged: mergedFacts > 0, aliasesAdded, trashPath };
|
|
@@ -1238,6 +1267,8 @@ export function countFacts(body: string): number {
|
|
|
1238
1267
|
|
|
1239
1268
|
export interface MoveJournalEntry {
|
|
1240
1269
|
op: "move";
|
|
1270
|
+
/** A merge keeps the target id and must be verified before source removal. */
|
|
1271
|
+
merge?: boolean;
|
|
1241
1272
|
/** Relative path of the source file. */
|
|
1242
1273
|
from: string;
|
|
1243
1274
|
/** Relative path of the destination file. */
|
|
@@ -1259,7 +1290,10 @@ export async function readJournal(root: string): Promise<MoveJournalEntry | unde
|
|
|
1259
1290
|
if (!raw) return undefined;
|
|
1260
1291
|
try {
|
|
1261
1292
|
const parsed = JSON.parse(raw) as MoveJournalEntry;
|
|
1262
|
-
|
|
1293
|
+
const safe = (path: string): boolean => isIndexableRelPath(path)
|
|
1294
|
+
&& !path.split("/").some((part) => part === "." || part === ".." || !part);
|
|
1295
|
+
if (parsed && parsed.op === "move" && typeof parsed.from === "string" && typeof parsed.to === "string"
|
|
1296
|
+
&& safe(parsed.from) && safe(parsed.to)) return parsed;
|
|
1263
1297
|
} catch {
|
|
1264
1298
|
// A corrupt journal is not worth failing over; the files themselves are
|
|
1265
1299
|
// authoritative and doctor reports duplicate ids.
|
|
@@ -1278,16 +1312,22 @@ export async function clearJournal(root: string): Promise<void> {
|
|
|
1278
1312
|
export async function recoverJournal(root: string): Promise<string | undefined> {
|
|
1279
1313
|
const entry = await readJournal(root);
|
|
1280
1314
|
if (!entry) return undefined;
|
|
1281
|
-
const fromPath =
|
|
1282
|
-
const toPath =
|
|
1315
|
+
const fromPath = await safeMemoryPath(root, entry.from);
|
|
1316
|
+
const toPath = await safeMemoryPath(root, entry.to);
|
|
1283
1317
|
let recovered: string | undefined;
|
|
1284
|
-
if (
|
|
1318
|
+
if (fromPath && toPath) {
|
|
1319
|
+
const [source, target] = await Promise.all([readMemoryDoc(root, fromPath), readMemoryDoc(root, toPath)]);
|
|
1320
|
+
const complete = source && target && (entry.merge
|
|
1321
|
+
? target.aliases.includes(source.id) && extractFactUnits(source.body).every((unit) => !appendFact(target.body, unit.label, unit.text).changed)
|
|
1322
|
+
: source.id === target.id);
|
|
1323
|
+
if (!complete) return `interrupted move kept source ${entry.from}: target is incomplete`;
|
|
1285
1324
|
await moveToTrash(fromPath, join(root, TRASH_DIR, "moved")).then(
|
|
1286
1325
|
() => {
|
|
1287
1326
|
recovered = `completed interrupted move ${entry.from} -> ${entry.to}`;
|
|
1288
1327
|
},
|
|
1289
1328
|
() => undefined,
|
|
1290
1329
|
);
|
|
1330
|
+
if (!recovered) return undefined;
|
|
1291
1331
|
}
|
|
1292
1332
|
await clearJournal(root).catch(() => {});
|
|
1293
1333
|
return recovered;
|
|
@@ -1320,9 +1360,36 @@ export async function readHot(root: string, limit: number): Promise<HotState> {
|
|
|
1320
1360
|
return { path, content, chars, limit, over: chars > limit, exists: true };
|
|
1321
1361
|
}
|
|
1322
1362
|
|
|
1323
|
-
/**
|
|
1324
|
-
|
|
1325
|
-
|
|
1363
|
+
/** Validate the complete briefing, without silently cutting or removing facts.
|
|
1364
|
+
* Semantic eligibility and correction checks remain the calling agent's job.
|
|
1365
|
+
* The legacy empty/template title is accepted and removed during migration.
|
|
1366
|
+
*/
|
|
1367
|
+
export function validateHot(content: string, limit = 5000): string {
|
|
1368
|
+
const prose = content.replace(/^# Memory[ \t]*(?:\r?\n|$)/, "").trim();
|
|
1369
|
+
if (/^\s*(?:#{1,6}\s|[-*+]\s|\d+[.)]\s|>|```|~~~|\|)/m.test(prose) || /<!--|-->/.test(prose)) {
|
|
1370
|
+
throw new Error("MEMORY.md must be plain prose: no headings, lists, tables, code fences or annotations.");
|
|
1371
|
+
}
|
|
1372
|
+
const normalized = prose ? `${prose}\n` : "";
|
|
1373
|
+
if (normalized.length > limit) throw new Error(`MEMORY.md exceeds its ${limit}-character budget. Rewrite more tightly; no content was saved or truncated.`);
|
|
1374
|
+
const seen = new Set<string>();
|
|
1375
|
+
for (const paragraph of normalized.split(/\n\s*\n/)) for (const unit of splitHotUnits(paragraph)) {
|
|
1376
|
+
const key = unit.toLowerCase().replace(/\s+/g, " ").trim();
|
|
1377
|
+
if (key && seen.has(key)) throw new Error("MEMORY.md contains a repeated sentence. Consolidate it before saving.");
|
|
1378
|
+
seen.add(key);
|
|
1379
|
+
}
|
|
1380
|
+
return normalized;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/** All supported briefing writes validate first and preserve the previous version. */
|
|
1384
|
+
export async function writeHot(root: string, content: string, limit = 5000): Promise<void> {
|
|
1385
|
+
const next = validateHot(content, limit);
|
|
1386
|
+
const path = join(root, HOT_FILE);
|
|
1387
|
+
const previous = await readFileOrUndefined(path);
|
|
1388
|
+
if (previous === next) return;
|
|
1389
|
+
if (previous !== undefined) {
|
|
1390
|
+
await atomicWriteFile(join(root, ".history", `${newMemoryId()}-MEMORY.md`), previous);
|
|
1391
|
+
}
|
|
1392
|
+
await atomicWriteFile(path, next);
|
|
1326
1393
|
}
|
|
1327
1394
|
|
|
1328
1395
|
/**
|
|
@@ -1426,7 +1493,8 @@ export interface HotAddResult {
|
|
|
1426
1493
|
* marker or anchor text is ever written into the file.
|
|
1427
1494
|
*/
|
|
1428
1495
|
export function addHotEntry(content: string, text: string, topic?: string): HotAddResult {
|
|
1429
|
-
|
|
1496
|
+
validateHot(text, Number.MAX_SAFE_INTEGER);
|
|
1497
|
+
const sentence = oneLine(text).trim();
|
|
1430
1498
|
if (!sentence) return { content, changed: false, paragraph: "", created: false };
|
|
1431
1499
|
const blocks = splitHotParagraphs(content);
|
|
1432
1500
|
const title = blocks.find(isTitleBlock);
|
|
@@ -1602,7 +1670,7 @@ export async function ensureStore(root: string, hotLimit: number, defaultCategor
|
|
|
1602
1670
|
await mkdir(join(root, LIBRARY_DIR, defaultCategory), { recursive: true });
|
|
1603
1671
|
await mkdir(join(root, INDEX_DIR), { recursive: true });
|
|
1604
1672
|
const hot = await readHot(root, hotLimit);
|
|
1605
|
-
if (!hot.exists) await writeHot(root, hotTemplate());
|
|
1673
|
+
if (!hot.exists) await writeHot(root, hotTemplate(), hotLimit);
|
|
1606
1674
|
}
|
|
1607
1675
|
|
|
1608
1676
|
/** Resolve a user-supplied memory reference (id, alias, relative path, or absolute path). */
|
|
@@ -1614,8 +1682,8 @@ export async function resolveMemoryRef(
|
|
|
1614
1682
|
const trimmed = ref.trim();
|
|
1615
1683
|
if (!trimmed) return undefined;
|
|
1616
1684
|
if (trimmed.endsWith(".md")) {
|
|
1617
|
-
const candidate =
|
|
1618
|
-
const direct = await readMemoryDoc(root, candidate);
|
|
1685
|
+
const candidate = await safeMemoryPath(root, trimmed);
|
|
1686
|
+
const direct = candidate ? await readMemoryDoc(root, candidate) : undefined;
|
|
1619
1687
|
if (direct) return direct;
|
|
1620
1688
|
}
|
|
1621
1689
|
const needle = trimmed.toLowerCase();
|
|
@@ -1631,11 +1699,24 @@ export async function resolveMemoryRef(
|
|
|
1631
1699
|
return undefined;
|
|
1632
1700
|
}
|
|
1633
1701
|
|
|
1634
|
-
/**
|
|
1702
|
+
/** Resolve only note files inside this store, including through symlinks. */
|
|
1703
|
+
async function safeMemoryPath(root: string, ref: string): Promise<string | undefined> {
|
|
1704
|
+
const target = resolve(root, ref);
|
|
1705
|
+
if (!isIndexableRelPath(normalizePath(relative(root, target)))) return undefined;
|
|
1706
|
+
try {
|
|
1707
|
+
const [realRoot, realTarget] = await Promise.all([realpath(root), realpath(target)]);
|
|
1708
|
+
if (!isIndexableRelPath(normalizePath(relative(realRoot, realTarget)))) return undefined;
|
|
1709
|
+
return target;
|
|
1710
|
+
} catch (error) {
|
|
1711
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
1712
|
+
throw error;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
/** Read a memory file inside this store, guarding traversal and symlinks. */
|
|
1635
1717
|
export async function readStoreFile(root: string, relOrAbs: string, maxChars = 40_000): Promise<{ path: string; content: string; truncated: boolean }> {
|
|
1636
|
-
const target =
|
|
1637
|
-
|
|
1638
|
-
if (!normalizePath(target).startsWith(normalizePath(normalizedRoot))) {
|
|
1718
|
+
const target = await safeMemoryPath(root, relOrAbs);
|
|
1719
|
+
if (!target) {
|
|
1639
1720
|
throw new Error(`Refusing to read outside the memoria root: ${relOrAbs}`);
|
|
1640
1721
|
}
|
|
1641
1722
|
const content = await readFile(target, "utf8");
|
package/src/tools.ts
CHANGED
|
@@ -6,8 +6,6 @@
|
|
|
6
6
|
* returned when explicitly requested.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { readFile } from "node:fs/promises";
|
|
10
|
-
import { isAbsolute, resolve } from "node:path";
|
|
11
9
|
import { Type } from "typebox";
|
|
12
10
|
import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
11
|
import { Text } from "@earendil-works/pi-tui";
|
|
@@ -15,12 +13,9 @@ import type { MemoriaRuntime } from "./runtime.ts";
|
|
|
15
13
|
import { readStoreFile } from "./store.ts";
|
|
16
14
|
import { renderSessionFallback, ripgrepNotice } from "./recall.ts";
|
|
17
15
|
import { formatSessionTime } from "./sessions.ts";
|
|
18
|
-
import {
|
|
16
|
+
import { oneLine, truncateChars } from "./util.ts";
|
|
19
17
|
import type { Priority, Scope, SearchHit, SessionScanStats } from "./types.ts";
|
|
20
18
|
|
|
21
|
-
/** Inline export/import payload cap; larger dumps must use a file path. */
|
|
22
|
-
const MAX_INLINE_TRANSFER_CHARS = 60_000;
|
|
23
|
-
|
|
24
19
|
export type RuntimeGetter = (ctx: ExtensionContext) => Promise<MemoriaRuntime>;
|
|
25
20
|
|
|
26
21
|
const ScopeEnum = Type.Union([Type.Literal("primary"), Type.Literal("all"), Type.Literal("project"), Type.Literal("global")], {
|
|
@@ -103,7 +98,7 @@ const WriteParams = Type.Object({
|
|
|
103
98
|
const HotParams = Type.Object({
|
|
104
99
|
action: Type.Union([Type.Literal("add"), Type.Literal("remove"), Type.Literal("replace"), Type.Literal("read"), Type.Literal("compact")], {
|
|
105
100
|
description:
|
|
106
|
-
"add: append a sentence to a theme paragraph. remove: delete every sentence containing a substring. replace: rewrite the whole file as prose. read: show it. compact:
|
|
101
|
+
"add: append a sentence to a theme paragraph. remove: delete every sentence containing a substring. replace: rewrite the whole file as prose. read: show it. compact: check the budget without deleting facts; if oversized, use replace with a tighter supported briefing.",
|
|
107
102
|
}),
|
|
108
103
|
topic: Type.Optional(
|
|
109
104
|
Type.String({
|
|
@@ -138,7 +133,7 @@ const MoveParams = Type.Object({
|
|
|
138
133
|
category: Type.Optional(Type.String({ description: "Target category folder. Defaults to the note's current category." })),
|
|
139
134
|
merge: Type.Optional(
|
|
140
135
|
Type.Boolean({
|
|
141
|
-
description: "When a note already exists at the target topic: true
|
|
136
|
+
description: "When a note already exists at the target topic: set true to fold this note's facts into it. Omitted or false refuses the merge.",
|
|
142
137
|
}),
|
|
143
138
|
),
|
|
144
139
|
keep_alias: Type.Optional(Type.Boolean({ description: "Record the note's previous topic and file name as aliases of the target (default true)." })),
|
|
@@ -169,29 +164,6 @@ const SessionsParams = Type.Object({
|
|
|
169
164
|
window: Type.Optional(Type.Number({ description: "For action=read: messages of context on each side (default 6, max 40)." })),
|
|
170
165
|
});
|
|
171
166
|
|
|
172
|
-
const ExportParams = Type.Object({
|
|
173
|
-
path: Type.Optional(
|
|
174
|
-
Type.String({
|
|
175
|
-
description: "Write the JSONL dump to this file (absolute, or relative to the working directory). Without it a bounded preview is returned inline.",
|
|
176
|
-
}),
|
|
177
|
-
),
|
|
178
|
-
include_hot: Type.Optional(Type.Boolean({ description: "Include MEMORY.md as a record (default true)." })),
|
|
179
|
-
scope: Type.Optional(ScopeEnum),
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
const ImportParams = Type.Object({
|
|
183
|
-
path: Type.Optional(Type.String({ description: "JSONL file to import (absolute, or relative to the working directory)." })),
|
|
184
|
-
content: Type.Optional(Type.String({ description: "Inline JSONL content. Prefer `path` for anything larger than a few notes." })),
|
|
185
|
-
mode: Type.Optional(
|
|
186
|
-
Type.Union([Type.Literal("merge"), Type.Literal("replace"), Type.Literal("skip")], {
|
|
187
|
-
description:
|
|
188
|
-
"merge (default): add facts that are missing from a note with the same id. replace: overwrite notes with the same id. skip: only import notes that do not exist yet.",
|
|
189
|
-
}),
|
|
190
|
-
),
|
|
191
|
-
dry_run: Type.Optional(Type.Boolean({ description: "Report what would happen without writing anything." })),
|
|
192
|
-
scope: Type.Optional(ScopeEnum),
|
|
193
|
-
});
|
|
194
|
-
|
|
195
167
|
const ForgetParams = Type.Object({
|
|
196
168
|
id: Type.String({ description: "Memory id to remove. The file is moved to .trash/ rather than deleted." }),
|
|
197
169
|
scope: Type.Optional(ScopeEnum),
|
|
@@ -238,10 +210,10 @@ export function registerMemoriaTools(pi: ExtensionAPI, getRuntime: RuntimeGetter
|
|
|
238
210
|
name: "memoria_recall",
|
|
239
211
|
label: "Recalling memories",
|
|
240
212
|
description:
|
|
241
|
-
"Search long-term memory (the memoria library) for anything the user has said or that was learned in past sessions. Use
|
|
242
|
-
promptSnippet: "memoria_recall: search
|
|
213
|
+
"Search long-term memory (the memoria library) for anything the user has said or that was learned in past sessions. Use when evidence already recalled into context is insufficient for a question about prior facts. Returns ranked notes with ids, paths and excerpts; call memoria_read for full text.",
|
|
214
|
+
promptSnippet: "memoria_recall: search prior facts when recalled context is insufficient",
|
|
243
215
|
promptGuidelines: [
|
|
244
|
-
"
|
|
216
|
+
"Use relevant recalled context directly; call memoria_recall when it is insufficient.",
|
|
245
217
|
"Never say you do not remember without calling memoria_recall first.",
|
|
246
218
|
"Use memoria_recall with several focused queries when a first search returns nothing but the topic is likely stored.",
|
|
247
219
|
],
|
|
@@ -433,7 +405,7 @@ export function registerMemoriaTools(pi: ExtensionAPI, getRuntime: RuntimeGetter
|
|
|
433
405
|
lines.push(`NOTE: ${result.topicNote}. Prefer a broad grouping name so related facts stay together.`);
|
|
434
406
|
}
|
|
435
407
|
lines.push(
|
|
436
|
-
`MEMORY.md is at ${hot.chars}/${hot.limit} chars${hot.over ? ' (OVER BUDGET —
|
|
408
|
+
`MEMORY.md is at ${hot.chars}/${hot.limit} chars${hot.over ? ' (OVER BUDGET — rewrite with memoria_hot { action: "replace" } or move detail into the library)' : ""}.`,
|
|
437
409
|
);
|
|
438
410
|
// Housekeeping hints are best-effort: a failure must not fail the write.
|
|
439
411
|
try {
|
|
@@ -498,6 +470,8 @@ export function registerMemoriaTools(pi: ExtensionAPI, getRuntime: RuntimeGetter
|
|
|
498
470
|
"MEMORY.md is loaded on every request, so it is only for facts that pay off that often: identity, timezone/locale, communication style, hard constraints, and the current state of active work.",
|
|
499
471
|
"Write MEMORY.md as a short, natural introduction to the user plus their standing context. Sentences and paragraphs only — no headings, no bullet lists, no 'Topic — fact' labels.",
|
|
500
472
|
"Keep occasional details (URLs, versions, file paths, history, rationale) in library notes; recall finds them when needed.",
|
|
473
|
+
"Before every edit, read the briefing. Consolidate existing statements instead of appending paraphrases. Apply explicit corrections in place; ask about ambiguous conflicts. Preserve unrelated standing facts. Eligibility and factual correctness require your judgment; deterministic validation cannot establish truth.",
|
|
474
|
+
"Do not repeat skill instructions or dated maintenance history. Every statement must be supported by the user or verified evidence.",
|
|
501
475
|
"When MEMORY.md grows past a few paragraphs, rewrite it tighter with memoria_hot { action: \"replace\" } and file the dropped detail into the library rather than appending forever.",
|
|
502
476
|
],
|
|
503
477
|
parameters: HotParams,
|
|
@@ -552,7 +526,7 @@ export function registerMemoriaTools(pi: ExtensionAPI, getRuntime: RuntimeGetter
|
|
|
552
526
|
return {
|
|
553
527
|
content: text(
|
|
554
528
|
`MEMORY.md compacted: ${result.before} → ${result.after}/${runtime.config.hotLimit} chars.${
|
|
555
|
-
result.before === result.after ? " Already under budget." : "
|
|
529
|
+
result.before === result.after ? " Already under budget." : " No facts are automatically dropped."
|
|
556
530
|
}${dropped}`,
|
|
557
531
|
),
|
|
558
532
|
details: { kind: "hot", chars: result.after, limit: runtime.config.hotLimit },
|
|
@@ -807,105 +781,6 @@ export function registerMemoriaTools(pi: ExtensionAPI, getRuntime: RuntimeGetter
|
|
|
807
781
|
},
|
|
808
782
|
});
|
|
809
783
|
|
|
810
|
-
pi.registerTool<typeof ExportParams, { kind: string; notes?: number; bytes?: number; path?: string }, unknown>({
|
|
811
|
-
name: "memoria_export",
|
|
812
|
-
label: "Exporting memories",
|
|
813
|
-
description:
|
|
814
|
-
"Export the memory store as JSONL (one record per line: notes plus MEMORY.md) for backup or migration. With `path` the full dump is written to that file; without it a bounded preview is returned. Pair with memoria_import to restore.",
|
|
815
|
-
promptSnippet: "memoria_export: dump the memory store to JSONL for backup or migration",
|
|
816
|
-
parameters: ExportParams,
|
|
817
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<any>> {
|
|
818
|
-
const runtime = await getRuntime(ctx);
|
|
819
|
-
const { jsonl, notes } = await runtime.exportJsonl({
|
|
820
|
-
scope: (params.scope as Scope | undefined) ?? "all",
|
|
821
|
-
includeHot: params.include_hot !== false,
|
|
822
|
-
});
|
|
823
|
-
if (params.path) {
|
|
824
|
-
const target = isAbsolute(params.path) ? params.path : resolve(ctx.cwd, params.path);
|
|
825
|
-
await atomicWriteFile(target, jsonl);
|
|
826
|
-
return {
|
|
827
|
-
content: text(`Exported ${notes} note(s) to ${target} (${jsonl.length} bytes of JSONL).`),
|
|
828
|
-
details: { kind: "export", notes, bytes: jsonl.length, path: target },
|
|
829
|
-
};
|
|
830
|
-
}
|
|
831
|
-
const preview = truncateChars(jsonl, MAX_INLINE_TRANSFER_CHARS);
|
|
832
|
-
const truncated = jsonl.length > preview.length;
|
|
833
|
-
return {
|
|
834
|
-
content: text(
|
|
835
|
-
`${notes} note(s) exported (${jsonl.length} bytes).${truncated ? `\n\nInline preview truncated at ${MAX_INLINE_TRANSFER_CHARS} chars; pass \`path\` to write the complete dump.` : ""}\n\n${preview}`,
|
|
836
|
-
),
|
|
837
|
-
details: { kind: "export", notes, bytes: jsonl.length },
|
|
838
|
-
};
|
|
839
|
-
},
|
|
840
|
-
renderCall(args, theme) {
|
|
841
|
-
return new Text(theme.fg("toolTitle", theme.bold("memoria_export ")) + theme.fg("dim", args.path ?? "inline preview"), 0, 0);
|
|
842
|
-
},
|
|
843
|
-
renderResult(result, _options, theme) {
|
|
844
|
-
const details = result.details as { notes?: number; path?: string } | undefined;
|
|
845
|
-
return new Text(theme.fg("success", `✓ exported ${details?.notes ?? 0} notes`) + theme.fg("dim", details?.path ? ` → ${details.path}` : ""), 0, 0);
|
|
846
|
-
},
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
pi.registerTool<typeof ImportParams, { kind: string; created?: number; updated?: number; skipped?: number; errors?: number }, unknown>({
|
|
850
|
-
name: "memoria_import",
|
|
851
|
-
label: "Importing memories",
|
|
852
|
-
description:
|
|
853
|
-
"Import notes from a JSONL file (or inline content) produced by memoria_export. Never overwrites an existing note unless mode=replace: merge adds only the facts that are missing, and a path already taken by another note gets a numbered sibling. Use dry_run first on an unfamiliar file.",
|
|
854
|
-
promptSnippet: "memoria_import: restore memories from a JSONL export",
|
|
855
|
-
promptGuidelines: [
|
|
856
|
-
"Run memoria_import with dry_run: true first when importing a file you did not create.",
|
|
857
|
-
"Prefer mode=merge (the default) when importing into a store that already has memories.",
|
|
858
|
-
],
|
|
859
|
-
parameters: ImportParams,
|
|
860
|
-
executionMode: "sequential",
|
|
861
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<any>> {
|
|
862
|
-
const runtime = await getRuntime(ctx);
|
|
863
|
-
let payload = params.content ?? "";
|
|
864
|
-
if (params.path) {
|
|
865
|
-
const target = isAbsolute(params.path) ? params.path : resolve(ctx.cwd, params.path);
|
|
866
|
-
const file = await readFile(target, "utf8");
|
|
867
|
-
if (file.length > 20_000_000) throw new Error(`Refusing to import ${file.length} chars; split the file first.`);
|
|
868
|
-
payload = payload ? `${file}\n${payload}` : file;
|
|
869
|
-
}
|
|
870
|
-
if (!payload.trim()) throw new Error("Nothing to import: pass `path` or `content`.");
|
|
871
|
-
const result = await runtime.importJsonl(payload, {
|
|
872
|
-
mode: (params.mode as "merge" | "replace" | "skip" | undefined) ?? "merge",
|
|
873
|
-
scope: (params.scope as Scope | undefined) ?? "primary",
|
|
874
|
-
dryRun: params.dry_run === true,
|
|
875
|
-
});
|
|
876
|
-
const lines = [
|
|
877
|
-
`${params.dry_run ? "Dry run: would import" : "Imported"} ${result.created.length} new note(s), updated ${result.updated.length}, skipped ${result.skipped}.`,
|
|
878
|
-
];
|
|
879
|
-
if (result.hotImported) lines.push("MEMORY.md was restored from the export.");
|
|
880
|
-
if (result.errors.length > 0) lines.push(`Skipped ${result.errors.length} malformed record(s):\n${result.errors.slice(0, 10).join("\n")}`);
|
|
881
|
-
return {
|
|
882
|
-
content: text(lines.join("\n")),
|
|
883
|
-
details: {
|
|
884
|
-
kind: "import",
|
|
885
|
-
created: result.created.length,
|
|
886
|
-
updated: result.updated.length,
|
|
887
|
-
skipped: result.skipped,
|
|
888
|
-
errors: result.errors.length,
|
|
889
|
-
},
|
|
890
|
-
};
|
|
891
|
-
},
|
|
892
|
-
renderCall(args, theme) {
|
|
893
|
-
return new Text(
|
|
894
|
-
theme.fg("toolTitle", theme.bold("memoria_import ")) + theme.fg("accent", oneLine(args.path ?? "inline", 60)) + theme.fg("dim", args.mode ? ` (${args.mode})` : ""),
|
|
895
|
-
0,
|
|
896
|
-
0,
|
|
897
|
-
);
|
|
898
|
-
},
|
|
899
|
-
renderResult(result, _options, theme) {
|
|
900
|
-
const details = result.details as { created?: number; updated?: number; skipped?: number } | undefined;
|
|
901
|
-
return new Text(
|
|
902
|
-
theme.fg("success", `✓ +${details?.created ?? 0} ~${details?.updated ?? 0}`) + theme.fg("dim", ` skipped ${details?.skipped ?? 0}`),
|
|
903
|
-
0,
|
|
904
|
-
0,
|
|
905
|
-
);
|
|
906
|
-
},
|
|
907
|
-
});
|
|
908
|
-
|
|
909
784
|
pi.registerTool<typeof ForgetParams, { kind: string; id?: string; trash?: string }, unknown>({
|
|
910
785
|
name: "memoria_forget",
|
|
911
786
|
label: "Forgetting a memory",
|
package/src/types.ts
CHANGED
|
@@ -349,6 +349,8 @@ export interface MemoriaConfig {
|
|
|
349
349
|
/** Auto-recall tuning. */
|
|
350
350
|
autoRecall: boolean;
|
|
351
351
|
autoRecallLimit: number;
|
|
352
|
+
/** Minimum fraction of the strongest eligible score for automatic recall. */
|
|
353
|
+
autoRecallMinRatio: number;
|
|
352
354
|
autoRecallMinScore: number;
|
|
353
355
|
autoRecallMaxChars: number;
|
|
354
356
|
/** Number of previous user turns folded into the recall query. */
|
|
@@ -402,16 +404,6 @@ export interface MemoriaConfig {
|
|
|
402
404
|
rerankTopK: number;
|
|
403
405
|
/** Reranker budget in milliseconds; falls back to lexical order on timeout. */
|
|
404
406
|
rerankTimeoutMs: number;
|
|
405
|
-
/** Automatic session extraction: off, on-settle (idle) or on-shutdown. */
|
|
406
|
-
autoLearn: string;
|
|
407
|
-
/** Minimum user turns before auto-learn considers a session. */
|
|
408
|
-
autoLearnMinTurns: number;
|
|
409
|
-
/** Minimum transcript characters before auto-learn considers a session. */
|
|
410
|
-
autoLearnMinChars: number;
|
|
411
|
-
/** Minimum time between automatic extractions. */
|
|
412
|
-
autoLearnCooldownMs: number;
|
|
413
|
-
/** Characters per extraction chunk when harvesting a session. */
|
|
414
|
-
learnChunkChars: number;
|
|
415
407
|
/** Idle delay after a filesystem event before refreshing the index. */
|
|
416
408
|
watcherSettleMs: number;
|
|
417
409
|
/** Index persistence format: auto (binary above a threshold), json or binary. */
|
package/src/usage.ts
CHANGED
|
@@ -34,6 +34,7 @@ function coerceEntry(raw: unknown): UsageEntry | undefined {
|
|
|
34
34
|
export class UsageStore {
|
|
35
35
|
private docs = new Map<string, UsageEntry>();
|
|
36
36
|
private changed = false;
|
|
37
|
+
private saving?: Promise<void>;
|
|
37
38
|
private lastSessionAt = 0;
|
|
38
39
|
|
|
39
40
|
constructor(root: string) {
|
|
@@ -137,10 +138,23 @@ export class UsageStore {
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
async save(): Promise<void> {
|
|
141
|
+
if (this.saving) {
|
|
142
|
+
await this.saving;
|
|
143
|
+
return this.save();
|
|
144
|
+
}
|
|
140
145
|
if (!this.changed) return;
|
|
141
146
|
this.changed = false;
|
|
142
147
|
const payload: UsageFile = { version: USAGE_VERSION, docs: Object.fromEntries(this.docs), lastSessionAt: this.lastSessionAt };
|
|
143
|
-
|
|
148
|
+
const saving = atomicWriteFile(join(this.root, INDEX_DIR, USAGE_FILE), JSON.stringify(payload));
|
|
149
|
+
this.saving = saving;
|
|
150
|
+
try {
|
|
151
|
+
await saving;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
this.changed = true;
|
|
154
|
+
throw error;
|
|
155
|
+
} finally {
|
|
156
|
+
if (this.saving === saving) this.saving = undefined;
|
|
157
|
+
}
|
|
144
158
|
}
|
|
145
159
|
|
|
146
160
|
/** Notes with no recorded use in `staleMs`, oldest first. */
|
package/src/util.ts
CHANGED
|
@@ -92,8 +92,8 @@ export function debounce<A extends unknown[]>(fn: (...args: A) => void, waitMs:
|
|
|
92
92
|
export async function atomicWriteFile(path: string, data: string | Uint8Array): Promise<void> {
|
|
93
93
|
await mkdir(dirname(path), { recursive: true });
|
|
94
94
|
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
95
|
-
await (typeof data === "string" ? writeFile(tmp, data, "utf8") : writeFile(tmp, data));
|
|
96
95
|
try {
|
|
96
|
+
await (typeof data === "string" ? writeFile(tmp, data, "utf8") : writeFile(tmp, data));
|
|
97
97
|
await rename(tmp, path);
|
|
98
98
|
} catch (error) {
|
|
99
99
|
await rm(tmp, { force: true }).catch(() => {});
|
|
@@ -132,7 +132,10 @@ export async function moveToTrash(path: string, trashDir: string): Promise<strin
|
|
|
132
132
|
await mkdir(trashDir, { recursive: true });
|
|
133
133
|
const base = path.split("/").pop() ?? "memory.md";
|
|
134
134
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
135
|
-
|
|
135
|
+
// Different categories can contain the same basename and be removed within
|
|
136
|
+
// one millisecond. A random suffix prevents rename() from replacing the
|
|
137
|
+
// earlier trashed file on POSIX filesystems.
|
|
138
|
+
const target = join(trashDir, `${stamp}_${randomBytes(8).toString("hex")}__${base}`);
|
|
136
139
|
await rename(path, target);
|
|
137
140
|
return target;
|
|
138
141
|
}
|