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/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
- const root = this.roots.find((candidate) => target === resolve(candidate) || target.startsWith(`${resolve(candidate)}${sep}`));
769
- if (!root || !existsSync(target)) return undefined;
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 { existsSync } from "node:fs";
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 (default) merges this note into
1094
- * it, false refuses and reports the conflict.
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 === false) {
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
- if (parsed && parsed.op === "move" && typeof parsed.from === "string" && typeof parsed.to === "string") return parsed;
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 = join(root, entry.from);
1282
- const toPath = join(root, entry.to);
1315
+ const fromPath = await safeMemoryPath(root, entry.from);
1316
+ const toPath = await safeMemoryPath(root, entry.to);
1283
1317
  let recovered: string | undefined;
1284
- if (existsSync(fromPath) && existsSync(toPath)) {
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
- /** Write MEMORY.md atomically. Callers enforce/validate the budget. */
1324
- export async function writeHot(root: string, content: string): Promise<void> {
1325
- await atomicWriteFile(join(root, HOT_FILE), content.endsWith("\n") ? content : `${content}\n`);
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
- const sentence = oneLine(text, 1200).replace(/^[-*]\s*/, "").trim();
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 = trimmed.startsWith("/") ? trimmed : resolve(root, trimmed);
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
- /** Read a range of lines from a file path inside the store, guarding traversal. */
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 = relOrAbs.startsWith("/") ? relOrAbs : resolve(root, relOrAbs);
1637
- const normalizedRoot = resolve(root);
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 { atomicWriteFile, oneLine, truncateChars } from "./util.ts";
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: trim to the character budget.",
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 (default) folds this note's facts into it, false refuses so you can choose another topic.",
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 this before answering questions about people, preferences, projects, decisions, past incidents or anything possibly stored earlier. Returns ranked notes with ids, paths and excerpts; call memoria_read for full text.",
242
- promptSnippet: "memoria_recall: search long-term memory before answering anything possibly known from before",
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
- "Call memoria_recall before answering questions about people, preferences, projects, decisions or prior conversations.",
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 — run memoria_hot { action: "compact" } or move detail into the library)' : ""}.`,
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." : " Least-important trailing paragraphs were dropped. Next time prefer action=replace with a tighter summary."
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
- await atomicWriteFile(join(this.root, INDEX_DIR, USAGE_FILE), JSON.stringify(payload));
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
- const target = join(trashDir, `${stamp}__${base}`);
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
  }