@pencil-agent/nano-pencil 1.14.2 → 1.14.3

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.
@@ -10,11 +10,11 @@ import { Type } from "@sinclair/typebox";
10
10
  import { SessionManager } from "@pencil-agent/nano-pencil";
11
11
  import { NanoMemEngine } from "./engine.js";
12
12
  import { reportDiagnostic } from "./diagnostics.js";
13
- import { readDreamLockMtimeMs, rollbackDreamLock, stampDreamLock, tryAcquireDreamLock } from "./dream-lock.js";
13
+ import { readDreamLockMtimeMs, rollbackDreamLock, tryAcquireDreamLock } from "./dream-lock.js";
14
14
  import { renderFullInsightsHtml } from "./full-insights-html.js";
15
15
  import { hasParseableLlmJson } from "./llm-json.js";
16
16
  import { extractTags } from "./scoring.js";
17
- import { loadEntries, loadMeta } from "./store.js";
17
+ import { loadMeta } from "./store.js";
18
18
  const extractedItemSchema = Type.Object({
19
19
  type: Type.Union([
20
20
  Type.Literal("preference"),
@@ -66,6 +66,9 @@ const memoryJsonContracts = {
66
66
  schema: Type.Object({ recommendations: Type.Array(Type.String()) }),
67
67
  },
68
68
  };
69
+ const dreamCommands = ["run", "status", "stop"];
70
+ const memoryEditFields = ["summary", "detail", "content", "salience", "ttl", "retention"];
71
+ const memoryResolveActions = ["merge", "demote", "forget", "mark-situational"];
69
72
  function withTimeout(promise, timeoutMs) {
70
73
  return new Promise((resolve) => {
71
74
  const timer = setTimeout(() => resolve(undefined), timeoutMs);
@@ -314,25 +317,51 @@ async function readMetaLastConsolidationMs(memoryDir) {
314
317
  return 0;
315
318
  }
316
319
  }
317
- async function countBaseMemoryEntries(memoryDir) {
318
- const [knowledge, lessons, events] = await Promise.all([
319
- loadEntries(join(memoryDir, "knowledge.json")),
320
- loadEntries(join(memoryDir, "lessons.json")),
321
- loadEntries(join(memoryDir, "events.json")),
322
- ]);
323
- return knowledge.length + lessons.length + events.length;
320
+ function formatElapsed(ms) {
321
+ const seconds = Math.max(0, Math.round(ms / 1000));
322
+ if (seconds < 60)
323
+ return `${seconds}s`;
324
+ const minutes = Math.floor(seconds / 60);
325
+ const remainder = seconds % 60;
326
+ return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`;
327
+ }
328
+ function describeDreamSource(source) {
329
+ return source === "auto" ? "automatic" : source === "manual" ? "manual" : "not started";
324
330
  }
325
- function formatDreamSummary(params) {
326
- const { added, candidates, memoryDir } = params;
327
- if (candidates <= 0) {
328
- return `Dream: no consolidation needed.\nMemory dir: ${memoryDir}`;
331
+ function formatDreamStatus(task, memoryDir) {
332
+ const lines = [`Memory refresh: ${task.status}`];
333
+ lines.push(`Mode: ${describeDreamSource(task.source)}`);
334
+ if (task.startedAtMs) {
335
+ const endedAt = task.endedAtMs ?? Date.now();
336
+ lines.push(`${task.status === "running" ? "Running for" : "Duration"}: ${formatElapsed(endedAt - task.startedAtMs)}`);
329
337
  }
330
- return [
331
- `Dream: consolidation completed.`,
332
- `Candidates processed: ${candidates}`,
333
- `New memories added: ${added}`,
334
- `Memory dir: ${memoryDir}`,
335
- ].join("\n");
338
+ if (task.sessionsReviewing)
339
+ lines.push(`Reviewing: ${task.sessionsReviewing} past sessions`);
340
+ if (task.result) {
341
+ lines.push(`Reviewed: ${task.result.episodesConsidered} past sessions`);
342
+ lines.push(`Memories: ${task.result.added} added, ${task.result.updated} updated, ${task.result.skipped} skipped`);
343
+ }
344
+ if (task.status === "running")
345
+ lines.push("Stop: /dream stop");
346
+ if (task.lastError)
347
+ lines.push(`Needs attention: ${task.lastError}`);
348
+ lines.push(`Saved in: ${memoryDir}`);
349
+ return lines.join("\n");
350
+ }
351
+ function formatDreamResult(result, memoryDir) {
352
+ const sample = result.entries
353
+ .slice(0, 5)
354
+ .map((entry) => `- [${entry.type}] ${entry.name || entry.summary || entry.id}`)
355
+ .join("\n");
356
+ const lines = [
357
+ "Memory refreshed.",
358
+ `Reviewed: ${result.stats.episodesConsidered} past sessions`,
359
+ `Memories: ${result.stats.added} added, ${result.stats.updated} updated, ${result.stats.skipped} skipped`,
360
+ `Saved in: ${memoryDir}`,
361
+ ];
362
+ if (sample)
363
+ lines.push("", "Examples:", sample);
364
+ return lines.join("\n");
336
365
  }
337
366
  export default function nanomemExtension(api) {
338
367
  const project = getProject();
@@ -440,67 +469,100 @@ export default function nanomemExtension(api) {
440
469
  console.error("[nanomem] startup maintenance failed:", error);
441
470
  }
442
471
  });
443
- const maybeRunAutoDream = async (ctx) => {
444
- const cfg = getDreamConfig(ctx);
445
- if (!cfg.enabled)
446
- return;
447
- if (dreamTask.status === "running")
448
- return;
449
- const lastAtMs = Math.max(await readMetaLastConsolidationMs(memoryDir), await readDreamLockMtimeMs(lockPath));
450
- const hoursSince = (Date.now() - lastAtMs) / 3_600_000;
451
- if (lastAtMs > 0 && hoursSince < cfg.minHours)
452
- return;
453
- const scanIntervalMs = cfg.scanIntervalMinutes * 60_000;
454
- if (Date.now() - lastSessionScanAtMs < scanIntervalMs)
455
- return;
456
- lastSessionScanAtMs = Date.now();
457
- const touchedCount = await SessionManager.countTouchedSince(ctx.cwd, lastAtMs, {
458
- excludeBasename: sessionId,
459
- });
460
- if (touchedCount < cfg.minSessions)
472
+ const runDreamTask = async (ctx, source, options = {}) => {
473
+ if (dreamTask.status === "running") {
474
+ if (options.notifyWhenDone) {
475
+ ctx.ui.notify("Memory refresh is already running. Use /dream status to check progress.", "info");
476
+ }
461
477
  return;
478
+ }
479
+ const cfg = getDreamConfig(ctx);
462
480
  const prior = await tryAcquireDreamLock(lockPath, cfg.holderStaleMinutes * 60_000);
463
- if (prior === null)
481
+ if (prior === null) {
482
+ if (options.notifyWhenDone) {
483
+ ctx.ui.notify("Memory refresh is already running in another session. Try /dream status later.", "info");
484
+ }
464
485
  return;
486
+ }
465
487
  const abort = new AbortController();
466
488
  dreamTask = {
467
489
  status: "running",
490
+ source,
468
491
  startedAtMs: Date.now(),
469
- sessionsReviewing: touchedCount,
492
+ sessionsReviewing: options.sessionsReviewing,
470
493
  priorLockMtimeMs: prior,
471
494
  abort,
472
495
  };
473
- ctx.ui.setStatus("nanomem", `Dream: running (${touchedCount} sessions)`);
496
+ ctx.ui.setStatus("nanomem", options.sessionsReviewing ? `Memory refresh: running (${options.sessionsReviewing} sessions)` : "Memory refresh: running");
497
+ bindLlm(ctx);
474
498
  try {
475
499
  const result = await engine.consolidateDetailed({ signal: abort.signal });
476
500
  dreamTask = {
477
501
  status: "completed",
502
+ source,
478
503
  startedAtMs: dreamTask.startedAtMs,
479
504
  endedAtMs: Date.now(),
480
- sessionsReviewing: touchedCount,
505
+ sessionsReviewing: options.sessionsReviewing,
506
+ result: result.stats,
481
507
  };
482
- ctx.ui.setStatus("nanomem", `Dream: completed (+${result.stats.added})`);
483
- if (result.stats.added + result.stats.updated > 0 && ctx.hasUI) {
484
- ctx.ui.notify(`Dream completed: +${result.stats.added} added, ${result.stats.updated} updated`, "info");
508
+ ctx.ui.setStatus("nanomem", `Memory refresh: done (+${result.stats.added})`);
509
+ if (options.notifyWhenDone || (ctx.hasUI && result.stats.added + result.stats.updated > 0)) {
510
+ ctx.ui.notify(formatDreamResult(result, memoryDir), "info");
485
511
  }
486
512
  }
487
- catch (e) {
488
- if (abort.signal.aborted || (e instanceof Error && e.message === "AbortError")) {
489
- // kill path handles rollback
513
+ catch (error) {
514
+ if (abort.signal.aborted || (error instanceof Error && error.message === "AbortError")) {
515
+ if (dreamTask.status !== "killed") {
516
+ dreamTask = {
517
+ status: "killed",
518
+ source,
519
+ startedAtMs: dreamTask.startedAtMs,
520
+ endedAtMs: Date.now(),
521
+ sessionsReviewing: options.sessionsReviewing,
522
+ };
523
+ await rollbackDreamLock(lockPath, prior);
524
+ ctx.ui.setStatus("nanomem", "Memory refresh: stopped");
525
+ }
490
526
  return;
491
527
  }
528
+ const message = error instanceof Error ? error.message : String(error);
492
529
  dreamTask = {
493
530
  status: "failed",
531
+ source,
494
532
  startedAtMs: dreamTask.startedAtMs,
495
533
  endedAtMs: Date.now(),
496
- sessionsReviewing: touchedCount,
497
- lastError: e instanceof Error ? e.message : String(e),
534
+ sessionsReviewing: options.sessionsReviewing,
535
+ lastError: message,
498
536
  priorLockMtimeMs: prior,
499
537
  };
500
- ctx.ui.setStatus("nanomem", "Dream: failed");
538
+ ctx.ui.setStatus("nanomem", "Memory refresh: failed");
501
539
  await rollbackDreamLock(lockPath, prior);
540
+ if (options.notifyWhenDone || ctx.hasUI) {
541
+ ctx.ui.notify(`Memory refresh failed.\nNeeds attention: ${message}`, "error");
542
+ }
502
543
  }
503
544
  };
545
+ const maybeRunAutoDream = async (ctx) => {
546
+ const cfg = getDreamConfig(ctx);
547
+ if (!cfg.enabled)
548
+ return;
549
+ if (dreamTask.status === "running")
550
+ return;
551
+ const lastAtMs = Math.max(await readMetaLastConsolidationMs(memoryDir), await readDreamLockMtimeMs(lockPath));
552
+ const hoursSince = (Date.now() - lastAtMs) / 3_600_000;
553
+ if (lastAtMs > 0 && hoursSince < cfg.minHours)
554
+ return;
555
+ const scanIntervalMs = cfg.scanIntervalMinutes * 60_000;
556
+ if (Date.now() - lastSessionScanAtMs < scanIntervalMs)
557
+ return;
558
+ lastSessionScanAtMs = Date.now();
559
+ const touchedCount = await SessionManager.countTouchedSince(ctx.cwd, lastAtMs, {
560
+ excludeBasename: sessionId,
561
+ });
562
+ if (touchedCount < cfg.minSessions)
563
+ return;
564
+ await runDreamTask(ctx, "auto", { sessionsReviewing: touchedCount });
565
+ };
504
566
  api.on("turn_end", async (_event, ctx) => {
505
567
  // Never block turn lifecycle
506
568
  void maybeRunAutoDream(ctx).catch(() => { });
@@ -591,50 +653,41 @@ export default function nanomemExtension(api) {
591
653
  }
592
654
  });
593
655
  api.registerCommand("dream", {
594
- description: "Consolidate NanoMem episodes into durable memories. Usage: /dream [run|stop|status]",
656
+ description: "Refresh long-term NanoMem memories. Usage: /dream [run|status|stop]",
657
+ getArgumentCompletions: (argumentPrefix) => {
658
+ const prefix = argumentPrefix.trim().toLowerCase();
659
+ const matches = dreamCommands.filter((command) => command.startsWith(prefix));
660
+ if (matches.length === 0)
661
+ return null;
662
+ return matches.map((command) => ({ value: command, label: command }));
663
+ },
595
664
  handler: async (args, ctx) => {
596
665
  const cmd = (args || "").trim().toLowerCase();
597
666
  if (cmd === "stop" || cmd === "kill" || cmd === "cancel") {
598
667
  if (dreamTask.status !== "running" || !dreamTask.abort) {
599
- ctx.ui.notify("Dream: no background task is running.", "info");
668
+ ctx.ui.notify("No memory refresh is running.", "info");
600
669
  return;
601
670
  }
671
+ const prior = dreamTask.priorLockMtimeMs;
602
672
  dreamTask.abort.abort();
603
673
  dreamTask.status = "killed";
604
674
  dreamTask.endedAtMs = Date.now();
605
- ctx.ui.setStatus("nanomem", "Dream: killed");
606
- if (typeof dreamTask.priorLockMtimeMs === "number") {
607
- await rollbackDreamLock(lockPath, dreamTask.priorLockMtimeMs);
675
+ ctx.ui.setStatus("nanomem", "Memory refresh: stopped");
676
+ if (typeof prior === "number") {
677
+ await rollbackDreamLock(lockPath, prior);
608
678
  }
609
- ctx.ui.notify("Dream: background task killed and lock rolled back.", "info");
679
+ ctx.ui.notify("Memory refresh stopped. You can run /dream again when ready.", "info");
610
680
  return;
611
681
  }
612
682
  if (cmd === "status") {
613
- const status = dreamTask.status;
614
- const sessions = dreamTask.sessionsReviewing ?? 0;
615
- const err = dreamTask.lastError ? `\nError: ${dreamTask.lastError}` : "";
616
- ctx.ui.notify(`Dream status: ${status}${sessions ? ` (${sessions} sessions)` : ""}${err}`, "info");
683
+ ctx.ui.notify(formatDreamStatus(dreamTask, memoryDir), "info");
617
684
  return;
618
685
  }
619
- // Manual run: not gate-checked and not blocked by lock. Always stamps lock to suppress immediate auto-dream.
620
- await stampDreamLock(lockPath);
621
- bindLlm(ctx);
622
- const result = await engine.consolidateDetailed();
623
- const sample = result.entries
624
- .slice(0, 5)
625
- .map((e) => `- [${e.type}] ${e.name || e.summary || e.id}`)
626
- .join("\n");
627
- const summary = [
628
- `Dream: consolidation completed.`,
629
- `Episodes considered: ${result.stats.episodesConsidered}`,
630
- `Added: ${result.stats.added} | Updated: ${result.stats.updated} | Skipped: ${result.stats.skipped}`,
631
- `Memory dir: ${memoryDir}`,
632
- sample ? "" : "",
633
- sample ? `\nExamples:\n${sample}` : "",
634
- ]
635
- .filter((x) => x !== "")
636
- .join("\n");
637
- ctx.ui.notify(summary, "info");
686
+ if (cmd && cmd !== "run") {
687
+ ctx.ui.notify("Usage: /dream [run|status|stop]", "warning");
688
+ return;
689
+ }
690
+ await runDreamTask(ctx, "manual", { notifyWhenDone: true });
638
691
  },
639
692
  });
640
693
  api.registerCommand("mem-search", {
@@ -694,11 +747,11 @@ export default function nanomemExtension(api) {
694
747
  }
695
748
  };
696
749
  api.registerCommand("mem-insights", {
697
- description: "Generate NanoMem full insights HTML report (uses LLM when available)",
750
+ description: "Create a readable NanoMem insights HTML report",
698
751
  handler: runMemInsights,
699
752
  });
700
753
  api.registerCommand("mem-align", {
701
- description: "Show which stable memories and current states are shaping the agent",
754
+ description: "Show which memories are shaping the current agent behavior",
702
755
  handler: async (_args, ctx) => {
703
756
  const snapshot = await engine.getAlignmentSnapshot();
704
757
  const topIdentity = snapshot.identityCore
@@ -718,7 +771,7 @@ export default function nanomemExtension(api) {
718
771
  },
719
772
  });
720
773
  api.registerCommand("mem-review", {
721
- description: "Review the highest-risk memory conflicts and suggested actions",
774
+ description: "Review memory conflicts and suggested actions",
722
775
  handler: async (_args, ctx) => {
723
776
  const snapshot = await engine.getAlignmentSnapshot();
724
777
  const topConflicts = snapshot.conflicts.slice(0, 3);
@@ -733,7 +786,14 @@ export default function nanomemExtension(api) {
733
786
  },
734
787
  });
735
788
  api.registerCommand("mem-edit", {
736
- description: "Edit a memory entry by ID. Usage: /mem-edit <id> <field> <value>",
789
+ description: "Edit one memory by ID. Usage: /mem-edit <id> <field> <value>",
790
+ getArgumentCompletions: (argumentPrefix) => {
791
+ const prefix = argumentPrefix.trim().toLowerCase();
792
+ const values = memoryEditFields
793
+ .filter((value) => value.startsWith(prefix))
794
+ .map((value) => ({ value, label: value }));
795
+ return values.length > 0 ? values : null;
796
+ },
737
797
  handler: async (args, ctx) => {
738
798
  const parts = (args || "").trim().split(/\s+/);
739
799
  const [id, field, ...rest] = parts;
@@ -751,6 +811,13 @@ export default function nanomemExtension(api) {
751
811
  });
752
812
  api.registerCommand("mem-resolve", {
753
813
  description: "Resolve a memory conflict. Usage: /mem-resolve <aId> <bId> [merge|demote|forget|mark-situational]",
814
+ getArgumentCompletions: (argumentPrefix) => {
815
+ const prefix = argumentPrefix.trim().toLowerCase();
816
+ const values = memoryResolveActions
817
+ .filter((value) => value.startsWith(prefix))
818
+ .map((value) => ({ value, label: value }));
819
+ return values.length > 0 ? values : null;
820
+ },
754
821
  handler: async (args, ctx) => {
755
822
  const [aId, bId, action] = (args || "").trim().split(/\s+/);
756
823
  if (!aId || !bId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pencil-agent/nano-pencil",
3
- "version": "1.14.2",
3
+ "version": "1.14.3",
4
4
  "description": "CLI writing agent with read, bash, edit, write tools and session management. Supports DashScope and Ali Token Plan. Soul enabled by default for AI personality evolution.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,7 +49,7 @@
49
49
  "test:team": "node --test --import tsx test/team-parser.test.ts test/team-runtime.test.ts",
50
50
  "test:plan": "node --test --import tsx test/plan-mode.test.ts",
51
51
  "test:utils": "node --test --import tsx test/logger.test.ts",
52
- "test:commands": "node --test --import tsx test/slash-command-catalog.test.ts",
52
+ "test:commands": "node --test --import tsx test/slash-command-catalog.test.ts test/extension-command-completions.test.ts",
53
53
  "test:security": "node --test --import tsx test/security-audit.test.ts",
54
54
  "test:tools": "node --test --import tsx test/workspace-write-guard.test.ts test/read-tool.test.ts test/tool-window-validation.test.ts test/bash-sandbox.test.ts",
55
55
  "test:presence": "node --test --import tsx test/presence-opening.test.ts",