pi-memoria 0.1.3 → 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/runtime.ts CHANGED
@@ -9,7 +9,6 @@
9
9
  * - Track hits/writes/last-used (`UsageStore`) outside the search path.
10
10
  * - Merge several roots with rank normalization so no store is drowned.
11
11
  * - Report health: duplicate topics, contradictions, staleness, promotion.
12
- * - Export and import the store as JSONL.
13
12
  */
14
13
 
15
14
  import { existsSync } from "node:fs";
@@ -20,18 +19,14 @@ import { DEFAULT_CONFIG, INDEX_DIR, LIBRARY_DIR, loadConfig, resolveRoots, resol
20
19
  import { MemoryIndex, createIndexSaver, isIgnoredWatchPath } from "./index-engine.ts";
21
20
  import {
22
21
  addHotEntry,
23
- appendFact,
24
22
  categorySummary,
25
23
  createMemory,
26
24
  deleteMemory,
27
- deriveSummary,
28
25
  ensureStore,
29
- extendSummary,
30
26
  extractFactUnits,
31
27
  hotTemplate,
32
28
  looksAtomicTopic,
33
29
  moveMemory,
34
- normalizePriority,
35
30
  priorityRank,
36
31
  readHot,
37
32
  recoverJournal,
@@ -39,9 +34,9 @@ import {
39
34
  renderLibraryIndexes,
40
35
  resolveMemoryRef,
41
36
  touchLastUsed,
42
- trimHot,
43
37
  updateMemory,
44
38
  writeHot,
39
+ validateHot,
45
40
  type CreateMemoryInput,
46
41
  type CreateMemoryResult,
47
42
  type IndexListingDoc,
@@ -53,7 +48,6 @@ import { SessionStore } from "./sessions.ts";
53
48
  import { UsageStore } from "./usage.ts";
54
49
  import { compareDocs, detectContradictions, isComparableTerm, suggestMerges, type SimilarityDoc } from "./similarity.ts";
55
50
  import { expandSynonymTable, loadSynonymsFile, mergeSynonymTables } from "./synonyms.ts";
56
- import { decodeJsonl, encodeJsonl, writeImportedNote, type ImportedNote } from "./transfer.ts";
57
51
  import { KeyedMutex, atomicWriteFile, debounce, normalizePath, oneLine, readFileOrUndefined } from "./util.ts";
58
52
  import type {
59
53
  Contradiction,
@@ -707,57 +701,6 @@ export class MemoriaRuntime {
707
701
  return { result, root };
708
702
  }
709
703
 
710
- /** Append a fact to an existing note by id (used by session learning). */
711
- async appendToNote(
712
- id: string,
713
- input: { content: string; label?: string; tags?: string[]; summary?: string; priority?: string; confidence?: string; source?: string },
714
- scope: Scope = "all",
715
- ): Promise<{ doc: MemoryDoc; duplicate: boolean } | undefined> {
716
- const candidates = scope === "all" ? this.activeRoots("all") : [this.rootForScope(scope)];
717
- for (const root of candidates) {
718
- const locked = await this.locks.run(root, async () => this.appendToNoteLocked(root, id, input));
719
- if (locked) {
720
- this.bumpCacheGeneration();
721
- return locked;
722
- }
723
- }
724
- return undefined;
725
- }
726
-
727
- private async appendToNoteLocked(
728
- root: string,
729
- id: string,
730
- input: { content: string; label?: string; tags?: string[]; summary?: string; priority?: string; confidence?: string; source?: string },
731
- ): Promise<{ doc: MemoryDoc; duplicate: boolean } | undefined> {
732
- const state = await this.storeFor(root);
733
- const idx = state.index.idxForId(id);
734
- if (idx === undefined) return undefined;
735
- const doc = await resolveMemoryRef(root, id, liveLookup(state.index));
736
- if (!doc) return undefined;
737
- const appended = appendFact(doc.body, input.label, input.content);
738
- if (!appended.changed) return { doc, duplicate: true };
739
- const tags = input.tags ? [...new Set([...doc.tags, ...input.tags])] : undefined;
740
- const priority = input.priority && priorityRank(normalizePriority(input.priority)) > priorityRank(doc.priority) ? input.priority : undefined;
741
- const summary = extendSummary(doc.summary, input.summary ?? deriveSummary(input.content));
742
- const updated = await updateMemory(root, doc, {
743
- content: appended.body,
744
- tags,
745
- summary,
746
- priority,
747
- confidence: input.confidence,
748
- source: input.source,
749
- });
750
- state.index.removeDoc(idx);
751
- state.index.addDoc(updated);
752
- state.index.builtAt = Date.now();
753
- state.saver.schedule();
754
- if (state.usage) {
755
- state.usage.record(updated.id, "write");
756
- state.usageSaver();
757
- }
758
- return { doc: updated, duplicate: false };
759
- }
760
-
761
704
  async updateMemoryById(
762
705
  id: string,
763
706
  input: UpdateMemoryInput & { scope?: Scope },
@@ -922,7 +865,7 @@ export class MemoriaRuntime {
922
865
  const state = await readHot(root, this.config.hotLimit);
923
866
  const content = state.exists ? state.content : hotTemplate();
924
867
  const added = addHotEntry(content, text, topic);
925
- if (added.changed) await writeHot(root, added.content);
868
+ if (added.changed) await writeHot(root, added.content, this.config.hotLimit);
926
869
  return {
927
870
  before: state.chars,
928
871
  after: added.content.length,
@@ -937,7 +880,7 @@ export class MemoriaRuntime {
937
880
  return this.locks.run(root, async () => {
938
881
  const state = await readHot(root, this.config.hotLimit);
939
882
  const [next, removed] = removeHotMatches(state.content, pattern);
940
- await writeHot(root, next);
883
+ await writeHot(root, next, this.config.hotLimit);
941
884
  return { before: state.chars, after: next.length, removed };
942
885
  });
943
886
  }
@@ -945,19 +888,20 @@ export class MemoriaRuntime {
945
888
  async hotReplace(content: string, root = this.roots.primary): Promise<{ before: number; after: number }> {
946
889
  return this.locks.run(root, async () => {
947
890
  const state = await readHot(root, this.config.hotLimit);
948
- await writeHot(root, content);
949
- return { before: state.chars, after: content.length };
891
+ await writeHot(root, content, this.config.hotLimit);
892
+ return { before: state.chars, after: validateHot(content, this.config.hotLimit).length };
950
893
  });
951
894
  }
952
895
 
953
- /** Trim MEMORY.md to the configured budget, reporting what was cut. */
896
+ /** Check the briefing without deleting facts to meet the budget. */
954
897
  async hotCompact(root = this.roots.primary): Promise<{ before: number; after: number; removed: string }> {
955
898
  return this.locks.run(root, async () => {
956
899
  const state = await readHot(root, this.config.hotLimit);
957
- if (!state.over) return { before: state.chars, after: state.chars, removed: "" };
958
- const trimmed = trimHot(state.content, this.config.hotLimit);
959
- await writeHot(root, trimmed.content);
960
- return { before: state.chars, after: trimmed.content.length, removed: trimmed.removed };
900
+ if (!state.over) {
901
+ validateHot(state.content, this.config.hotLimit);
902
+ return { before: state.chars, after: state.chars, removed: "" };
903
+ }
904
+ throw new Error("MEMORY.md is over budget. Read it and use action=replace with a tighter briefing preserving standing facts; compact never drops facts automatically.");
961
905
  });
962
906
  }
963
907
 
@@ -1284,159 +1228,6 @@ export class MemoriaRuntime {
1284
1228
  return { since, created: created.sort(byRecency), updated: updated.sort(byRecency) };
1285
1229
  }
1286
1230
 
1287
- /* ---------------------------------------------------------------- */
1288
- /* Export / import */
1289
- /* ---------------------------------------------------------------- */
1290
-
1291
- /** Serialize the store as JSONL (one record per line, plus MEMORY.md). */
1292
- async exportJsonl(options: { scope?: Scope; includeHot?: boolean; includeTrash?: boolean } = {}): Promise<{ jsonl: string; notes: number }> {
1293
- const roots = this.activeRoots(options.scope ?? "all");
1294
- const lines: string[] = [];
1295
- let notes = 0;
1296
- for (const root of roots) {
1297
- const state = await this.storeFor(root);
1298
- await this.refreshIndex(state.index);
1299
- const hot = await readHot(root, this.config.hotLimit);
1300
- if (options.includeHot !== false && hot.exists && hot.content.trim()) {
1301
- lines.push(encodeJsonl({ type: "hot", root: this.displayPath(root), content: hot.content }));
1302
- }
1303
- for (const { idx, meta } of state.index.liveDocs()) {
1304
- const body = await state.index.bodyFor(idx);
1305
- lines.push(
1306
- encodeJsonl({
1307
- type: "memory",
1308
- id: meta.id,
1309
- relPath: meta.relPath,
1310
- root: this.displayPath(root),
1311
- title: meta.title,
1312
- category: meta.category,
1313
- tags: meta.tags,
1314
- aliases: meta.aliases,
1315
- related: meta.related,
1316
- supersedes: meta.supersedes,
1317
- summary: meta.summary,
1318
- priority: meta.priority,
1319
- confidence: meta.confidence,
1320
- created: meta.created,
1321
- updated: meta.updated,
1322
- body,
1323
- }),
1324
- );
1325
- notes += 1;
1326
- }
1327
- }
1328
- return { jsonl: `${lines.join("\n")}\n`, notes };
1329
- }
1330
-
1331
- /** Import JSONL produced by `exportJsonl` into the primary (or project) store. */
1332
- async importJsonl(
1333
- text: string,
1334
- options: { mode?: "merge" | "replace" | "skip"; scope?: Scope; dryRun?: boolean } = {},
1335
- ): Promise<{ created: string[]; updated: string[]; skipped: number; errors: string[]; hotImported: boolean }> {
1336
- const mode = options.mode ?? "merge";
1337
- const root = this.rootForScope(options.scope ?? "primary");
1338
- await ensureStore(root, this.config.hotLimit, this.config.defaultCategory);
1339
- const state = await this.storeFor(root);
1340
- const records = decodeJsonl(text);
1341
- const result = { created: [] as string[], updated: [] as string[], skipped: 0, errors: records.errors, hotImported: false };
1342
- return this.locks.run(root, async () => {
1343
- let changed = false;
1344
- let completed = false;
1345
- try {
1346
- for (const record of records.records) {
1347
- if (record.type === "hot") {
1348
- if (options.dryRun) continue;
1349
- // Already inside the root lock: call the store helpers directly.
1350
- await writeHot(root, record.content);
1351
- result.hotImported = true;
1352
- continue;
1353
- }
1354
- if (!options.dryRun) changed = true;
1355
- const outcome = await this.importNote(root, state, record, mode, options.dryRun === true);
1356
- if (!options.dryRun && outcome !== "skipped") changed = true;
1357
- if (outcome === "created") result.created.push(record.id);
1358
- else if (outcome === "updated") result.updated.push(record.id);
1359
- else result.skipped += 1;
1360
- }
1361
- completed = true;
1362
- return result;
1363
- } finally {
1364
- if (changed) {
1365
- this.bumpCacheGeneration();
1366
- if (!completed) state.index.dirty = true;
1367
- state.index.builtAt = Date.now();
1368
- state.saver.schedule();
1369
- }
1370
- }
1371
- });
1372
- }
1373
-
1374
- private async importNote(
1375
- root: string,
1376
- state: StoreState,
1377
- note: ImportedNote,
1378
- mode: "merge" | "replace" | "skip",
1379
- dryRun: boolean,
1380
- ): Promise<"created" | "updated" | "skipped"> {
1381
- const existingIdx = state.index.idxForId(note.id);
1382
- const existing = existingIdx !== undefined ? await resolveMemoryRef(root, note.id, liveLookup(state.index)) : undefined;
1383
- if (existing) {
1384
- if (mode === "skip") return "skipped";
1385
- if (dryRun) return "updated";
1386
- if (mode === "replace") {
1387
- const updated = await updateMemory(root, existing, {
1388
- title: note.title,
1389
- content: note.body,
1390
- tags: note.tags,
1391
- aliases: note.aliases,
1392
- related: note.related,
1393
- supersedes: note.supersedes,
1394
- summary: note.summary,
1395
- priority: note.priority,
1396
- confidence: note.confidence,
1397
- category: note.category || existing.category,
1398
- });
1399
- state.index.removeDoc(existingIdx!);
1400
- state.index.addDoc(updated);
1401
- return "updated";
1402
- }
1403
- // merge: fold in the fact units that are not already present.
1404
- let body = existing.body;
1405
- let changed = false;
1406
- for (const unit of extractFactUnits(note.body)) {
1407
- const appended = appendFact(body, unit.label, unit.text);
1408
- if (appended.changed) {
1409
- body = appended.body;
1410
- changed = true;
1411
- }
1412
- }
1413
- const newTags = note.tags.filter((tag) => !existing.tags.includes(tag));
1414
- const newAliases = note.aliases.filter((alias) => !existing.aliases.some((current) => current.toLowerCase() === alias.toLowerCase()));
1415
- const tags = newTags.length > 0 ? [...new Set([...existing.tags, ...newTags])] : undefined;
1416
- const aliases = newAliases.length > 0 ? [...new Set([...existing.aliases, ...newAliases])] : undefined;
1417
- if (!changed && !tags && !aliases) return "skipped";
1418
- const updated = await updateMemory(root, existing, {
1419
- content: changed ? body : undefined,
1420
- tags,
1421
- aliases,
1422
- summary: changed ? extendSummary(existing.summary, note.summary) : undefined,
1423
- });
1424
- state.index.removeDoc(existingIdx!);
1425
- state.index.addDoc(updated);
1426
- return "updated";
1427
- }
1428
- if (dryRun) return "created";
1429
- // `writeImportedNote` avoids the path when it is taken by a different note,
1430
- // so an import can never overwrite an existing memory.
1431
- const written = await writeImportedNote(root, note);
1432
- const stale = state.index.idxForRelPath(written.relPath);
1433
- if (stale >= 0) state.index.removeDoc(stale);
1434
- const staleId = state.index.idxForId(written.id);
1435
- if (staleId !== undefined && staleId !== stale) state.index.removeDoc(staleId);
1436
- state.index.addDoc(written);
1437
- return "created";
1438
- }
1439
-
1440
1231
  /**
1441
1232
  * One pass over the store producing both structured findings and the
1442
1233
  * human-readable list: `/memoria doctor` renders the structure, the tool and
package/src/store.ts CHANGED
@@ -1360,9 +1360,36 @@ export async function readHot(root: string, limit: number): Promise<HotState> {
1360
1360
  return { path, content, chars, limit, over: chars > limit, exists: true };
1361
1361
  }
1362
1362
 
1363
- /** Write MEMORY.md atomically. Callers enforce/validate the budget. */
1364
- export async function writeHot(root: string, content: string): Promise<void> {
1365
- 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);
1366
1393
  }
1367
1394
 
1368
1395
  /**
@@ -1466,7 +1493,8 @@ export interface HotAddResult {
1466
1493
  * marker or anchor text is ever written into the file.
1467
1494
  */
1468
1495
  export function addHotEntry(content: string, text: string, topic?: string): HotAddResult {
1469
- const sentence = oneLine(text, 1200).replace(/^[-*]\s*/, "").trim();
1496
+ validateHot(text, Number.MAX_SAFE_INTEGER);
1497
+ const sentence = oneLine(text).trim();
1470
1498
  if (!sentence) return { content, changed: false, paragraph: "", created: false };
1471
1499
  const blocks = splitHotParagraphs(content);
1472
1500
  const title = blocks.find(isTitleBlock);
@@ -1642,7 +1670,7 @@ export async function ensureStore(root: string, hotLimit: number, defaultCategor
1642
1670
  await mkdir(join(root, LIBRARY_DIR, defaultCategory), { recursive: true });
1643
1671
  await mkdir(join(root, INDEX_DIR), { recursive: true });
1644
1672
  const hot = await readHot(root, hotLimit);
1645
- if (!hot.exists) await writeHot(root, hotTemplate());
1673
+ if (!hot.exists) await writeHot(root, hotTemplate(), hotLimit);
1646
1674
  }
1647
1675
 
1648
1676
  /** Resolve a user-supplied memory reference (id, alias, relative path, or absolute path). */
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({
@@ -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. */