@pencil-agent/nano-mem 0.0.1 → 1.0.2

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.
Files changed (64) hide show
  1. package/README.md +5 -5
  2. package/dist/cli.js +1 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +16 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +10 -1
  7. package/dist/config.js.map +1 -1
  8. package/dist/consolidation.d.ts.map +1 -1
  9. package/dist/consolidation.js +25 -8
  10. package/dist/consolidation.js.map +1 -1
  11. package/dist/engine.d.ts +7 -1
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js +181 -65
  14. package/dist/engine.js.map +1 -1
  15. package/dist/extension.d.ts.map +1 -1
  16. package/dist/extension.js +70 -1
  17. package/dist/extension.js.map +1 -1
  18. package/dist/extraction.d.ts.map +1 -1
  19. package/dist/extraction.js +19 -2
  20. package/dist/extraction.js.map +1 -1
  21. package/dist/full-insights.d.ts.map +1 -1
  22. package/dist/full-insights.js +6 -5
  23. package/dist/full-insights.js.map +1 -1
  24. package/dist/i18n.d.ts +3 -0
  25. package/dist/i18n.d.ts.map +1 -1
  26. package/dist/i18n.js +56 -24
  27. package/dist/i18n.js.map +1 -1
  28. package/dist/index.d.ts +3 -3
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/insights-html.js +1 -1
  33. package/dist/insights-html.js.map +1 -1
  34. package/dist/linking.js +1 -1
  35. package/dist/linking.js.map +1 -1
  36. package/dist/scoring.d.ts +10 -1
  37. package/dist/scoring.d.ts.map +1 -1
  38. package/dist/scoring.js +33 -0
  39. package/dist/scoring.js.map +1 -1
  40. package/dist/store.d.ts +5 -0
  41. package/dist/store.d.ts.map +1 -1
  42. package/dist/store.js +40 -2
  43. package/dist/store.js.map +1 -1
  44. package/dist/types.d.ts +22 -2
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/update.d.ts.map +1 -1
  47. package/dist/update.js +22 -10
  48. package/dist/update.js.map +1 -1
  49. package/package.json +2 -3
  50. package/src/cli.ts +1 -1
  51. package/src/config.ts +27 -1
  52. package/src/consolidation.ts +33 -9
  53. package/src/engine.ts +218 -118
  54. package/src/extension.ts +77 -1
  55. package/src/extraction.ts +19 -2
  56. package/src/full-insights.ts +6 -5
  57. package/src/i18n.ts +62 -24
  58. package/src/index.ts +4 -1
  59. package/src/insights-html.ts +1 -1
  60. package/src/linking.ts +1 -1
  61. package/src/scoring.ts +48 -1
  62. package/src/store.ts +41 -2
  63. package/src/types.ts +25 -2
  64. package/src/update.ts +22 -10
package/src/engine.ts CHANGED
@@ -12,10 +12,11 @@ import { getConfig, type NanomemConfig } from "./config.js";
12
12
  import { consolidateEpisodes } from "./consolidation.js";
13
13
  import { utilityEntry, utilityWork } from "./eviction.js";
14
14
  import { extractMemories, extractWork } from "./extraction.js";
15
+ import type { PromptSet } from "./i18n.js";
15
16
  import { PROMPTS } from "./i18n.js";
16
17
  import { getRelatedSummaries, linkNewEntry } from "./linking.js";
17
18
  import { evictExpiredEntries, evictExpiredWork, filterByScope, filterPII } from "./privacy.js";
18
- import { extractTags, pickTop, scoreEntry, scoreEpisode, scoreWorkEntry, tagOverlap } from "./scoring.js";
19
+ import { extractTags, pickTop, scoreEntry, scoreEpisode, scoreWorkEntry, tagOverlap, tierEntries } from "./scoring.js";
19
20
  import {
20
21
  loadEntries,
21
22
  loadEpisodes,
@@ -115,6 +116,7 @@ export class NanoMemEngine {
115
116
  id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
116
117
  goal: sessionGoal || extracted.goal,
117
118
  summary: filterPII(extracted.summary),
119
+ detail: extracted.detail ? filterPII(extracted.detail) : undefined,
118
120
  project,
119
121
  tags: extractTags(`${extracted.goal} ${extracted.summary}`),
120
122
  importance: 6,
@@ -173,7 +175,7 @@ export class NanoMemEngine {
173
175
  return newEntries;
174
176
  }
175
177
 
176
- // ─── Retrieval & Injection ───────────────────────────────────
178
+ // ─── Retrieval & Injection (Progressive Recall) ────────────
177
179
 
178
180
  async getMemoryInjection(project: string, contextTags: string[], scope?: MemoryScope): Promise<string> {
179
181
  const [allKnowledge, allLessons, allPrefs, allFacets, allEpisodes, allWork] = await Promise.all([
@@ -192,79 +194,159 @@ export class NanoMemEngine {
192
194
  const work = this.filterAndCleanWork(allWork, scope);
193
195
  const episodes = filterByScope(allEpisodes, scope);
194
196
 
195
- const totalBudget = this.cfg.tokenBudget;
196
- const b = this.cfg.budget;
197
197
  const hl = this.cfg.halfLife;
198
198
  const sw = this.cfg.scoreWeights;
199
+ const pr = this.cfg.progressiveRecall;
199
200
  const p = PROMPTS[this.cfg.locale] ?? PROMPTS.en;
200
201
 
201
- const charBudget = (ratio: number) => Math.floor(totalBudget * 4 * ratio);
202
-
203
- const topLessons = pickTop(
204
- lessons,
205
- (e) => scoreEntry(e, project, contextTags, hl, sw),
206
- (e) => e.content.length,
207
- charBudget(b.lessons),
208
- );
209
-
210
- const topKnowledge = pickTop(
211
- knowledge,
212
- (e) => scoreEntry(e, project, contextTags, hl, sw),
213
- (e) => e.content.length,
214
- charBudget(b.knowledge),
215
- );
216
-
217
- const topPrefs = pickTop(
218
- prefs,
219
- (e) => scoreEntry(e, project, contextTags, hl, sw),
220
- (e) => e.content.length,
221
- charBudget(b.preferences),
222
- );
223
-
224
- const topFacets = pickTop(
225
- facets,
226
- (e) => scoreEntry(e, project, contextTags, hl, sw),
227
- (e) => e.content.length,
228
- charBudget(b.facets),
229
- );
230
-
202
+ // Tier all MemoryEntry categories
203
+ const tieredKnowledge = tierEntries(knowledge, project, contextTags, hl, sw, pr);
204
+ const tieredLessons = tierEntries(lessons, project, contextTags, hl, sw, pr);
205
+ const tieredPrefs = tierEntries(prefs, project, contextTags, hl, sw, pr);
206
+ const tieredFacets = tierEntries(facets, project, contextTags, hl, sw, pr);
207
+
208
+ // Budget calculation
209
+ const totalChars = this.cfg.tokenBudget * 4;
210
+ const activeChars = Math.floor(totalChars * pr.budgetActive);
211
+ const cueChars = Math.floor(totalChars * pr.budgetCue);
212
+
213
+ // Entry length helpers
214
+ const activeLen = (e: MemoryEntry) =>
215
+ (e.name?.length || 0) + (e.summary?.length || 0) + (e.detail?.length || 0) + 30;
216
+ const cueLen = (e: MemoryEntry) => (e.name?.length || 0) + (e.summary?.length || 0) + 30;
217
+ const scoreFn = (e: MemoryEntry) => scoreEntry(e, project, contextTags, hl, sw);
218
+
219
+ // Active tier: pick top entries with full detail, split budget across categories
220
+ const activeBudgetPer = Math.floor(activeChars / 4);
221
+ const activeKnowledge = pickTop(tieredKnowledge.active, scoreFn, activeLen, activeBudgetPer);
222
+ const activeLessons = pickTop(tieredLessons.active, scoreFn, activeLen, activeBudgetPer);
223
+ const activePrefs = pickTop(tieredPrefs.active, scoreFn, activeLen, activeBudgetPer);
224
+ const activeFacets = pickTop(tieredFacets.active, scoreFn, activeLen, activeBudgetPer);
225
+
226
+ // Cue tier: pick top entries with name + summary + id, split budget across categories
227
+ const cueBudgetPer = Math.floor(cueChars / 6); // 6 = knowledge + lessons + prefs + facets + episodes + work
228
+ const cueKnowledge = pickTop(tieredKnowledge.cue, scoreFn, cueLen, cueBudgetPer);
229
+ const cueLessons = pickTop(tieredLessons.cue, scoreFn, cueLen, cueBudgetPer);
230
+ const cuePrefs = pickTop(tieredPrefs.cue, scoreFn, cueLen, cueBudgetPer);
231
+ const cueFacets = pickTop(tieredFacets.cue, scoreFn, cueLen, cueBudgetPer);
232
+
233
+ // Episodes and Work use their existing scoring for cue layer
231
234
  const topEpisodes = pickTop(
232
235
  episodes,
233
236
  (ep) => scoreEpisode(ep, project, contextTags, hl, sw),
234
- (ep) => ep.summary.length,
235
- charBudget(b.episodes),
237
+ (ep) => ep.summary.length + 30,
238
+ cueBudgetPer,
236
239
  );
237
-
238
240
  const topWork = pickTop(
239
241
  work,
240
242
  (w) => scoreWorkEntry(w, project, contextTags, hl, sw),
241
- (w) => w.goal.length + w.summary.length,
242
- charBudget(b.work),
243
+ (w) => w.goal.length + w.summary.length + 30,
244
+ cueBudgetPer,
243
245
  );
244
246
 
245
- await this.reinforceEntries(topLessons, allLessons, this.lessonsPath);
246
- await this.reinforceEntries(topKnowledge, allKnowledge, this.knowledgePath);
247
- await this.reinforceEntries(topPrefs, allPrefs, this.preferencesPath);
248
- await this.reinforceEntries(topFacets, allFacets, this.facetsPath);
247
+ // Reinforce all recalled entries (Active + Cue) via spaced repetition
248
+ const allRecalledKnowledge = [...activeKnowledge, ...cueKnowledge];
249
+ const allRecalledLessons = [...activeLessons, ...cueLessons];
250
+ const allRecalledPrefs = [...activePrefs, ...cuePrefs];
251
+ const allRecalledFacets = [...activeFacets, ...cueFacets];
252
+
253
+ await this.reinforceEntries(allRecalledKnowledge, allKnowledge, this.knowledgePath);
254
+ await this.reinforceEntries(allRecalledLessons, allLessons, this.lessonsPath);
255
+ await this.reinforceEntries(allRecalledPrefs, allPrefs, this.preferencesPath);
256
+ await this.reinforceEntries(allRecalledFacets, allFacets, this.facetsPath);
249
257
  await this.reinforceWork(topWork, allWork);
250
258
 
259
+ // Optional reconsolidation for low-relevance recalled entries
251
260
  if (this.llmFn) {
252
- await this.reconsolidateIfNeeded(topKnowledge, contextTags, allKnowledge);
253
- await this.reconsolidateIfNeeded(topLessons, contextTags, allLessons);
261
+ await this.reconsolidateIfNeeded(allRecalledKnowledge, contextTags, allKnowledge);
262
+ await this.reconsolidateIfNeeded(allRecalledLessons, contextTags, allLessons);
254
263
  }
255
264
 
256
- return this.buildInjectionText(
257
- topLessons,
258
- topKnowledge,
259
- topEpisodes,
260
- topPrefs,
261
- topWork,
262
- topFacets,
265
+ return this.buildProgressiveInjectionText(
266
+ {
267
+ knowledge: activeKnowledge,
268
+ lessons: activeLessons,
269
+ preferences: activePrefs,
270
+ facets: activeFacets,
271
+ },
272
+ {
273
+ knowledge: cueKnowledge,
274
+ lessons: cueLessons,
275
+ preferences: cuePrefs,
276
+ facets: cueFacets,
277
+ episodes: topEpisodes,
278
+ work: topWork,
279
+ },
263
280
  allKnowledge,
264
281
  p,
265
282
  );
266
283
  }
267
284
 
285
+ // ─── Progressive Recall Tools ───────────────────────────────
286
+
287
+ /** Retrieve a single entry by ID (for recall_memory tool) */
288
+ async getEntryById(id: string): Promise<MemoryEntry | null> {
289
+ const paths = [this.knowledgePath, this.lessonsPath, this.preferencesPath, this.facetsPath];
290
+ for (const path of paths) {
291
+ const entries = await loadEntries(path);
292
+ const entry = entries.find((e) => e.id === id);
293
+ if (entry) return entry;
294
+ }
295
+ return null;
296
+ }
297
+
298
+ /** Reinforce a single entry by ID (bump accessCount, lastAccessed, strength) */
299
+ async reinforceEntryById(id: string): Promise<boolean> {
300
+ const pathConfigs = [
301
+ { path: this.knowledgePath, max: this.cfg.maxEntries.knowledge },
302
+ { path: this.lessonsPath, max: this.cfg.maxEntries.lessons },
303
+ { path: this.preferencesPath, max: this.cfg.maxEntries.preferences },
304
+ { path: this.facetsPath, max: this.cfg.maxEntries.facets },
305
+ ];
306
+ const hl = this.cfg.halfLife;
307
+ const ew = this.cfg.evictionWeights;
308
+
309
+ for (const { path, max } of pathConfigs) {
310
+ const entries = await loadEntries(path);
311
+ const entry = entries.find((e) => e.id === id);
312
+ if (entry) {
313
+ entry.accessCount = (entry.accessCount ?? 0) + 1;
314
+ entry.lastAccessed = new Date().toISOString();
315
+ entry.strength = (entry.strength || 30) * this.cfg.strengthGrowthFactor;
316
+ await saveEntries(path, entries, max, (e) => utilityEntry(e, hl, ew));
317
+ return true;
318
+ }
319
+ }
320
+ return false;
321
+ }
322
+
323
+ /** Full-text search across ALL entries including dormant (for search_all_memories tool) */
324
+ async searchAllEntries(query: string, limit = 10): Promise<MemoryEntry[]> {
325
+ const tags = extractTags(query);
326
+ const queryLower = query.toLowerCase();
327
+
328
+ const [knowledge, lessons, prefs, facets] = await Promise.all([
329
+ loadEntries(this.knowledgePath),
330
+ loadEntries(this.lessonsPath),
331
+ loadEntries(this.preferencesPath),
332
+ loadEntries(this.facetsPath),
333
+ ]);
334
+
335
+ const all = [...knowledge, ...lessons, ...prefs, ...facets];
336
+
337
+ return all
338
+ .map((e) => {
339
+ const nameMatch = (e.name || "").toLowerCase().includes(queryLower) ? 2 : 0;
340
+ const summaryMatch = (e.summary || "").toLowerCase().includes(queryLower) ? 1 : 0;
341
+ const tagMatch = tagOverlap(e.tags, tags);
342
+ return { entry: e, score: nameMatch + summaryMatch + tagMatch };
343
+ })
344
+ .filter((x) => x.score > 0)
345
+ .sort((a, b) => b.score - a.score)
346
+ .slice(0, limit)
347
+ .map((x) => x.entry);
348
+ }
349
+
268
350
  // ─── Stats ───────────────────────────────────────────────────
269
351
 
270
352
  async getStats(): Promise<{
@@ -404,8 +486,8 @@ export class NanoMemEngine {
404
486
  .map((e) => ({
405
487
  entry: e,
406
488
  weight: calcWeight(e),
407
- trigger: e.facetData?.kind === "pattern" ? e.facetData.trigger : e.content.slice(0, 50),
408
- behavior: e.facetData?.kind === "pattern" ? e.facetData.behavior : e.content,
489
+ trigger: e.facetData?.kind === "pattern" ? e.facetData.trigger : (e.summary || e.detail || "").slice(0, 50),
490
+ behavior: e.facetData?.kind === "pattern" ? e.facetData.behavior : (e.summary || e.detail || ""),
409
491
  }))
410
492
  .sort((a, b) => b.weight - a.weight);
411
493
 
@@ -416,7 +498,7 @@ export class NanoMemEngine {
416
498
  return {
417
499
  entry: e,
418
500
  weight: calcWeight(e, !isResolved),
419
- problem: e.facetData?.kind === "struggle" ? e.facetData.problem : e.content,
501
+ problem: e.facetData?.kind === "struggle" ? e.facetData.problem : (e.summary || e.detail || ""),
420
502
  attempts: e.facetData?.kind === "struggle" ? e.facetData.attempts : [],
421
503
  solution: e.facetData?.kind === "struggle" ? e.facetData.solution : "",
422
504
  resolved: isResolved,
@@ -462,7 +544,7 @@ export class NanoMemEngine {
462
544
  const input = JSON.stringify({
463
545
  patterns: patterns.slice(0, 5).map((pa) => ({ trigger: pa.trigger, behavior: pa.behavior })),
464
546
  struggles: struggles.slice(0, 5).map((s) => ({ problem: s.problem, resolved: s.resolved })),
465
- lessons: lessons.slice(0, 5).map((l) => l.content),
547
+ lessons: lessons.slice(0, 5).map((l) => l.summary || l.detail || l.content || ""),
466
548
  });
467
549
  const raw = await this.llmFn(p.insightsRecommendationSystem, input);
468
550
  const cleaned = raw
@@ -609,13 +691,17 @@ export class NanoMemEngine {
609
691
  const overlap = tagOverlap(entry.tags, contextTags);
610
692
  if (overlap >= 0.3) continue;
611
693
  try {
694
+ const memText = entry.detail || entry.summary || entry.content || "";
612
695
  const updated = await this.llmFn(
613
696
  p.reconsolidationSystem,
614
- `Original memory: ${entry.content}\n\nCurrent context tags: ${contextTags.join(", ")}`,
697
+ `Original memory: ${memText}\n\nCurrent context tags: ${contextTags.join(", ")}`,
615
698
  );
616
699
  if (updated && updated.length > 10) {
617
- entry.content = updated.trim();
618
- entry.tags = extractTags(entry.content);
700
+ const trimmed = updated.trim();
701
+ entry.detail = trimmed;
702
+ entry.content = trimmed;
703
+ entry.summary = trimmed.length <= 150 ? trimmed : `${trimmed.slice(0, 147)}...`;
704
+ entry.tags = extractTags(trimmed);
619
705
  }
620
706
  } catch {
621
707
  /* graceful degradation */
@@ -623,74 +709,88 @@ export class NanoMemEngine {
623
709
  }
624
710
  }
625
711
 
626
- private buildInjectionText(
627
- lessons: MemoryEntry[],
628
- knowledge: MemoryEntry[],
629
- episodes: Episode[],
630
- prefs: MemoryEntry[],
631
- work: WorkEntry[],
632
- facets: MemoryEntry[],
633
- allKnowledge: MemoryEntry[],
634
- p: {
635
- sectionLessons: string;
636
- sectionKnowledge: string;
637
- sectionEpisodes: string;
638
- sectionPreferences: string;
639
- sectionWork: string;
640
- sectionPatterns: string;
641
- sectionStruggles: string;
642
- injectionHeader: string;
643
- memoryBehavior: string;
712
+ private buildProgressiveInjectionText(
713
+ active: {
714
+ knowledge: MemoryEntry[];
715
+ lessons: MemoryEntry[];
716
+ preferences: MemoryEntry[];
717
+ facets: MemoryEntry[];
718
+ },
719
+ cue: {
720
+ knowledge: MemoryEntry[];
721
+ lessons: MemoryEntry[];
722
+ preferences: MemoryEntry[];
723
+ facets: MemoryEntry[];
724
+ episodes: Episode[];
725
+ work: WorkEntry[];
644
726
  },
727
+ allKnowledge: MemoryEntry[],
728
+ p: PromptSet,
645
729
  ): string {
646
730
  const sections: string[] = [];
647
731
 
648
- if (lessons.length) {
649
- sections.push(`### ${p.sectionLessons}\n${lessons.map((e) => `- ${e.content}`).join("\n")}`);
650
- }
651
- if (knowledge.length) {
652
- const lines = knowledge.map((e) => {
653
- const related = getRelatedSummaries(e, allKnowledge, 2);
654
- const suffix = related.length ? ` [→ ${related.join("; ")}]` : "";
655
- return `- ${e.content}${suffix}`;
656
- });
657
- sections.push(`### ${p.sectionKnowledge}\n${lines.join("\n")}`);
732
+ // ── Active tier: full detail ──
733
+ const activeLines: string[] = [];
734
+
735
+ const formatActiveEntry = (e: MemoryEntry): string => {
736
+ const related = getRelatedSummaries(e, allKnowledge, 2);
737
+ const suffix = related.length ? ` [→ ${related.join("; ")}]` : "";
738
+ return `- [ID: ${e.id}] **${e.name || "—"}**: ${e.summary || ""}\n ${e.detail || ""}${suffix}`;
739
+ };
740
+
741
+ const formatActiveFacet = (e: MemoryEntry): string => {
742
+ if (e.facetData?.kind === "pattern") {
743
+ return `- [ID: ${e.id}] **${e.name || "—"}**: When ${e.facetData.trigger} → ${e.facetData.behavior}\n ${e.detail || ""}`;
744
+ }
745
+ if (e.facetData?.kind === "struggle") {
746
+ return `- [ID: ${e.id}] **${e.name || "—"}**: ${e.facetData.problem}\n Tried: ${e.facetData.attempts.join(", ")} | Solved: ${e.facetData.solution}\n ${e.detail || ""}`;
747
+ }
748
+ return formatActiveEntry(e);
749
+ };
750
+
751
+ for (const e of active.lessons) activeLines.push(formatActiveEntry(e));
752
+ for (const e of active.knowledge) activeLines.push(formatActiveEntry(e));
753
+ for (const e of active.preferences) activeLines.push(formatActiveEntry(e));
754
+ for (const e of active.facets) activeLines.push(formatActiveFacet(e));
755
+
756
+ if (activeLines.length) {
757
+ sections.push(`### ${p.sectionActiveMemories}\n${activeLines.join("\n")}`);
658
758
  }
659
- if (episodes.length) {
660
- const lines = episodes.map(
661
- (ep) => `- [${ep.date}] ${ep.project}: ${ep.summary}${ep.userGoal ? ` (Goal: ${ep.userGoal})` : ""}`,
759
+
760
+ // ── Cue tier: name + summary + id ──
761
+ const cueLines: string[] = [];
762
+
763
+ const formatCueEntry = (e: MemoryEntry): string =>
764
+ `- [ID: ${e.id}] [${e.type}] **${e.name || "—"}**: ${e.summary || ""}`;
765
+
766
+ const formatCueFacet = (e: MemoryEntry): string => {
767
+ if (e.facetData?.kind === "pattern") {
768
+ return `- [ID: ${e.id}] [pattern] **${e.name || "—"}**: When ${e.facetData.trigger} → ${e.facetData.behavior}`;
769
+ }
770
+ if (e.facetData?.kind === "struggle") {
771
+ return `- [ID: ${e.id}] [struggle] **${e.name || "—"}**: ${e.facetData.problem}`;
772
+ }
773
+ return formatCueEntry(e);
774
+ };
775
+
776
+ for (const e of cue.lessons) cueLines.push(formatCueEntry(e));
777
+ for (const e of cue.knowledge) cueLines.push(formatCueEntry(e));
778
+ for (const e of cue.preferences) cueLines.push(formatCueEntry(e));
779
+ for (const e of cue.facets) cueLines.push(formatCueFacet(e));
780
+
781
+ // Episodes in cue layer (no ID-based recall)
782
+ for (const ep of cue.episodes) {
783
+ cueLines.push(
784
+ `- [${ep.date}] ${ep.project}: ${ep.summary}${ep.userGoal ? ` (Goal: ${ep.userGoal})` : ""}`,
662
785
  );
663
- sections.push(`### ${p.sectionEpisodes}\n${lines.join("\n")}`);
664
- }
665
- if (prefs.length) {
666
- sections.push(`### ${p.sectionPreferences}\n${prefs.map((e) => `- ${e.content}`).join("\n")}`);
667
786
  }
668
- if (work.length) {
669
- const lines = work.map((w) => `- [${w.created.slice(0, 10)}] ${w.goal}: ${w.summary}`);
670
- sections.push(`### ${p.sectionWork}\n${lines.join("\n")}`);
787
+ // Work in cue layer
788
+ for (const w of cue.work) {
789
+ cueLines.push(`- [${w.created.slice(0, 10)}] ${w.goal}: ${w.summary}`);
671
790
  }
672
791
 
673
- // Facets: patterns and struggles
674
- const patterns = facets.filter((e) => e.type === "pattern");
675
- const struggles = facets.filter((e) => e.type === "struggle");
676
-
677
- if (patterns.length) {
678
- const lines = patterns.map((e) => {
679
- if (e.facetData?.kind === "pattern") {
680
- return `- When ${e.facetData.trigger} → ${e.facetData.behavior}`;
681
- }
682
- return `- ${e.content}`;
683
- });
684
- sections.push(`### ${p.sectionPatterns}\n${lines.join("\n")}`);
685
- }
686
- if (struggles.length) {
687
- const lines = struggles.map((e) => {
688
- if (e.facetData?.kind === "struggle") {
689
- return `- Problem: ${e.facetData.problem} | Tried: ${e.facetData.attempts.join(", ")} | Solved: ${e.facetData.solution}`;
690
- }
691
- return `- ${e.content}`;
692
- });
693
- sections.push(`### ${p.sectionStruggles}\n${lines.join("\n")}`);
792
+ if (cueLines.length) {
793
+ sections.push(`### ${p.sectionMemoryCues}\n*${p.memoryCueHint}*\n${cueLines.join("\n")}`);
694
794
  }
695
795
 
696
796
  if (!sections.length) return "";
package/src/extension.ts CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { writeFileSync } from "node:fs";
11
11
  import { basename } from "node:path";
12
+ import { Type } from "@sinclair/typebox";
12
13
  import type { ExtensionAPI, ExtensionContext } from "@pencil-agent/nano-pencil";
13
14
  import { NanoMemEngine } from "./engine.js";
14
15
  import { renderFullInsightsHtml } from "./full-insights-html.js";
@@ -251,7 +252,7 @@ export default function nanomemExtension(pi: ExtensionAPI) {
251
252
  ctx.ui.notify("NanoMem: no matching memories found", "info");
252
253
  return;
253
254
  }
254
- for (const e of results.slice(0, 10)) ctx.ui.notify(`[${e.type}] ${e.content.slice(0, 80)}`, "info");
255
+ for (const e of results.slice(0, 10)) ctx.ui.notify(`[${e.type}] ${(e.summary || e.detail || e.content || "").slice(0, 80)}`, "info");
255
256
  },
256
257
  });
257
258
 
@@ -287,4 +288,79 @@ export default function nanomemExtension(pi: ExtensionAPI) {
287
288
  ctx.ui.notify(`NanoMem: Insights report written to ${outputPath}`, "info");
288
289
  },
289
290
  });
291
+
292
+ // ─── Progressive Recall Agent Tools ─────────────────────────
293
+
294
+ pi.registerTool({
295
+ name: "recall_memory",
296
+ label: "Recall Memory",
297
+ description:
298
+ "Retrieve full details of a specific memory entry by its ID. " +
299
+ "Use this when you see a memory cue (name + summary) in context and need the complete information. " +
300
+ "The ID looks like k_101, l_42, p_7, etc.",
301
+ parameters: Type.Object({
302
+ id: Type.String({ description: "The memory entry ID shown in [ID: xxx] cues" }),
303
+ }),
304
+ async execute(_toolCallId, params) {
305
+ const entry = await engine.getEntryById(params.id);
306
+ if (!entry) {
307
+ return {
308
+ content: [{ type: "text" as const, text: `No memory found with ID: ${params.id}` }],
309
+ details: undefined,
310
+ };
311
+ }
312
+
313
+ // Reinforce the entry (spaced repetition: bump accessCount, lastAccessed, strength)
314
+ await engine.reinforceEntryById(params.id);
315
+
316
+ const name = entry.name || "Untitled";
317
+ const summary = entry.summary || "";
318
+ const detail = entry.detail || entry.content || "";
319
+ const lines = [`[${entry.type}] ${name}`];
320
+ if (summary) lines.push("", summary);
321
+ if (detail && detail !== summary) lines.push("", detail);
322
+ if (entry.tags?.length) lines.push("", `Tags: ${entry.tags.join(", ")}`);
323
+
324
+ return {
325
+ content: [{ type: "text" as const, text: lines.join("\n") }],
326
+ details: undefined,
327
+ };
328
+ },
329
+ });
330
+
331
+ pi.registerTool({
332
+ name: "search_all_memories",
333
+ label: "Search All Memories",
334
+ description:
335
+ "Search across ALL stored memories including dormant ones not shown in the current context. " +
336
+ "Use this to find relevant past knowledge, lessons, preferences, or decisions " +
337
+ "when the memory cues in context don't cover what you need.",
338
+ parameters: Type.Object({
339
+ query: Type.String({ description: "Search keywords or phrase" }),
340
+ limit: Type.Optional(Type.Number({ description: "Max results (default 10)", default: 10 })),
341
+ }),
342
+ async execute(_toolCallId, params) {
343
+ const limit = params.limit ?? 10;
344
+ const results = await engine.searchAllEntries(params.query, limit);
345
+ if (!results.length) {
346
+ return {
347
+ content: [{ type: "text" as const, text: `No memories found matching: ${params.query}` }],
348
+ details: undefined,
349
+ };
350
+ }
351
+
352
+ const lines = [`Found ${results.length} memories for "${params.query}":\n`];
353
+ for (const e of results) {
354
+ const name = e.name || "Untitled";
355
+ const summary = e.summary || e.content?.slice(0, 100) || "";
356
+ lines.push(`- [ID: ${e.id}] [${e.type}] **${name}**: ${summary}`);
357
+ }
358
+ lines.push("", "Use `recall_memory` with an ID to get full details.");
359
+
360
+ return {
361
+ content: [{ type: "text" as const, text: lines.join("\n") }],
362
+ details: undefined,
363
+ };
364
+ },
365
+ });
290
366
  }
package/src/extraction.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { NanomemConfig } from "./config.js";
8
8
  import { PROMPTS } from "./i18n.js";
9
+ import { deriveNameFromContent, deriveSummaryFromContent } from "./store.js";
9
10
  import type { ExtractedItem, ExtractedWork, LlmFn } from "./types.js";
10
11
 
11
12
  export async function extractMemories(
@@ -25,7 +26,17 @@ async function extractWithLLM(conversation: string, cfg: NanomemConfig, llmFn: L
25
26
  .replace(/```json?\n?/g, "")
26
27
  .replace(/```/g, "")
27
28
  .trim();
28
- return JSON.parse(cleaned) as ExtractedItem[];
29
+ const items = JSON.parse(cleaned) as ExtractedItem[];
30
+ // Normalize: ensure name/summary/detail are populated (backward compat with old LLM responses)
31
+ return items.map((item) => {
32
+ const detail = item.detail || item.content || "";
33
+ return {
34
+ ...item,
35
+ name: item.name || deriveNameFromContent(detail),
36
+ summary: item.summary || deriveSummaryFromContent(detail),
37
+ detail,
38
+ };
39
+ });
29
40
  } catch {
30
41
  return extractHeuristic(conversation);
31
42
  }
@@ -40,7 +51,12 @@ function extractHeuristic(text: string): ExtractedItem[] {
40
51
  // Skip very short or already seen content
41
52
  if (trimmed.length < 8 || seen.has(trimmed.toLowerCase())) return;
42
53
  seen.add(trimmed.toLowerCase());
43
- items.push({ type, content: trimmed });
54
+ items.push({
55
+ type,
56
+ name: deriveNameFromContent(trimmed),
57
+ summary: deriveSummaryFromContent(trimmed),
58
+ detail: trimmed,
59
+ });
44
60
  };
45
61
 
46
62
  // Lesson patterns: errors, fixes, bugs, warnings, failures
@@ -148,5 +164,6 @@ function extractWorkHeuristic(text: string): ExtractedWork | null {
148
164
  return {
149
165
  goal: lines[0]?.slice(0, 100) ?? "unknown task",
150
166
  summary: `Session with ${lines.length} exchanges`,
167
+ detail: lines.slice(0, 20).join("\n"),
151
168
  };
152
169
  }
@@ -132,8 +132,8 @@ function buildPatternsAndStruggles(facets: MemoryEntry[]): { patterns: PatternIn
132
132
  .map((e) => ({
133
133
  entry: e,
134
134
  weight: calcWeight(e),
135
- trigger: e.facetData?.kind === "pattern" ? e.facetData.trigger : e.content.slice(0, 50),
136
- behavior: e.facetData?.kind === "pattern" ? e.facetData.behavior : e.content,
135
+ trigger: e.facetData?.kind === "pattern" ? e.facetData.trigger : (e.summary || e.detail || e.content || "").slice(0, 50),
136
+ behavior: e.facetData?.kind === "pattern" ? e.facetData.behavior : (e.summary || e.detail || e.content || ""),
137
137
  }))
138
138
  .sort((a, b) => b.weight - a.weight);
139
139
  const struggles: StruggleInsight[] = struggleEntries
@@ -142,7 +142,7 @@ function buildPatternsAndStruggles(facets: MemoryEntry[]): { patterns: PatternIn
142
142
  return {
143
143
  entry: e,
144
144
  weight: calcWeight(e, !isResolved),
145
- problem: e.facetData?.kind === "struggle" ? e.facetData.problem : e.content,
145
+ problem: e.facetData?.kind === "struggle" ? e.facetData.problem : (e.summary || e.detail || e.content || ""),
146
146
  attempts: e.facetData?.kind === "struggle" ? e.facetData.attempts : [],
147
147
  solution: e.facetData?.kind === "struggle" ? e.facetData.solution : "",
148
148
  resolved: isResolved,
@@ -202,7 +202,8 @@ function fallbackWins(struggles: StruggleInsight[], lessons: MemoryEntry[]): Ful
202
202
  }
203
203
  const sortLessons = [...lessons].sort((a, b) => b.importance * (b.accessCount + 1) - a.importance * (a.accessCount + 1));
204
204
  for (const l of sortLessons.slice(0, 3)) {
205
- wins.push({ title: l.content.slice(0, 60), description: l.content });
205
+ const text = l.summary || l.detail || l.content || "";
206
+ wins.push({ title: text.slice(0, 60), description: text });
206
207
  }
207
208
  return wins.slice(0, 8);
208
209
  }
@@ -349,7 +350,7 @@ export async function buildFullInsightsReport(
349
350
  const context = {
350
351
  patterns: patterns.slice(0, 5).map((x) => ({ trigger: x.trigger, behavior: x.behavior })),
351
352
  struggles: struggles.slice(0, 8).map((s) => ({ problem: s.problem, resolved: s.resolved, attempts: s.attempts, solution: s.solution })),
352
- lessons: topLessons.slice(0, 5).map((l) => l.content),
353
+ lessons: topLessons.slice(0, 5).map((l) => l.summary || l.detail || l.content || ""),
353
354
  projectAreas: projectAreas.map((a) => ({ name: a.name, sessionCount: a.sessionCount, firstSummary: a.description })),
354
355
  topTools: toolsChart.rows.slice(0, 5),
355
356
  errorsSummary: errChart.rows.slice(0, 5).map((r) => r.label),