@pcircle/memesh 4.9.3 → 4.10.1

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 (132) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/AGENTS.md +35 -4
  5. package/README.de.md +3 -3
  6. package/README.md +3 -3
  7. package/README.zh-TW.md +3 -3
  8. package/dashboard/dist/index.html +8 -8
  9. package/dist/cli/view-live.d.ts.map +1 -1
  10. package/dist/cli/view-live.js +0 -427
  11. package/dist/cli/view-live.js.map +1 -1
  12. package/dist/core/briefing-index.d.ts +30 -0
  13. package/dist/core/briefing-index.d.ts.map +1 -0
  14. package/dist/core/briefing-index.js +140 -0
  15. package/dist/core/briefing-index.js.map +1 -0
  16. package/dist/core/briefing.d.ts +4 -0
  17. package/dist/core/briefing.d.ts.map +1 -1
  18. package/dist/core/briefing.js +42 -4
  19. package/dist/core/briefing.js.map +1 -1
  20. package/dist/core/capture-liveness.d.ts +136 -0
  21. package/dist/core/capture-liveness.d.ts.map +1 -0
  22. package/dist/core/capture-liveness.js +447 -0
  23. package/dist/core/capture-liveness.js.map +1 -0
  24. package/dist/core/config.d.ts +1 -0
  25. package/dist/core/config.d.ts.map +1 -1
  26. package/dist/core/config.js +3 -1
  27. package/dist/core/config.js.map +1 -1
  28. package/dist/core/delegation.d.ts +47 -0
  29. package/dist/core/delegation.d.ts.map +1 -0
  30. package/dist/core/delegation.js +174 -0
  31. package/dist/core/delegation.js.map +1 -0
  32. package/dist/core/doctor-fixes.d.ts +1 -0
  33. package/dist/core/doctor-fixes.d.ts.map +1 -1
  34. package/dist/core/doctor-fixes.js +21 -0
  35. package/dist/core/doctor-fixes.js.map +1 -1
  36. package/dist/core/doctor.d.ts +8 -0
  37. package/dist/core/doctor.d.ts.map +1 -1
  38. package/dist/core/doctor.js +148 -3
  39. package/dist/core/doctor.js.map +1 -1
  40. package/dist/core/note-derive.d.ts +14 -0
  41. package/dist/core/note-derive.d.ts.map +1 -0
  42. package/dist/core/note-derive.js +66 -0
  43. package/dist/core/note-derive.js.map +1 -0
  44. package/dist/core/note-ingest.d.ts +34 -0
  45. package/dist/core/note-ingest.d.ts.map +1 -0
  46. package/dist/core/note-ingest.js +445 -0
  47. package/dist/core/note-ingest.js.map +1 -0
  48. package/dist/core/operations.d.ts +10 -1
  49. package/dist/core/operations.d.ts.map +1 -1
  50. package/dist/core/operations.js +129 -14
  51. package/dist/core/operations.js.map +1 -1
  52. package/dist/core/paths.d.ts.map +1 -1
  53. package/dist/core/paths.js.map +1 -1
  54. package/dist/core/schema-export.d.ts.map +1 -1
  55. package/dist/core/schema-export.js +10 -4
  56. package/dist/core/schema-export.js.map +1 -1
  57. package/dist/core/serializer.d.ts.map +1 -1
  58. package/dist/core/serializer.js +3 -0
  59. package/dist/core/serializer.js.map +1 -1
  60. package/dist/core/session-insight.d.ts +44 -0
  61. package/dist/core/session-insight.d.ts.map +1 -0
  62. package/dist/core/session-insight.js +216 -0
  63. package/dist/core/session-insight.js.map +1 -0
  64. package/dist/core/task-state-store.d.ts +4 -0
  65. package/dist/core/task-state-store.d.ts.map +1 -1
  66. package/dist/core/task-state-store.js +18 -5
  67. package/dist/core/task-state-store.js.map +1 -1
  68. package/dist/core/turn-signal.d.ts +22 -0
  69. package/dist/core/turn-signal.d.ts.map +1 -0
  70. package/dist/core/turn-signal.js +89 -0
  71. package/dist/core/turn-signal.js.map +1 -0
  72. package/dist/core/types.d.ts +12 -3
  73. package/dist/core/types.d.ts.map +1 -1
  74. package/dist/core/update-entrypoint.d.ts +19 -0
  75. package/dist/core/update-entrypoint.d.ts.map +1 -0
  76. package/dist/core/update-entrypoint.js +222 -0
  77. package/dist/core/update-entrypoint.js.map +1 -0
  78. package/dist/core/update-notice.d.ts +69 -0
  79. package/dist/core/update-notice.d.ts.map +1 -0
  80. package/dist/core/update-notice.js +188 -0
  81. package/dist/core/update-notice.js.map +1 -0
  82. package/dist/host-runtime/codex-session.d.ts +10 -0
  83. package/dist/host-runtime/codex-session.d.ts.map +1 -1
  84. package/dist/host-runtime/codex-session.js +1 -1
  85. package/dist/host-runtime/codex-session.js.map +1 -1
  86. package/dist/knowledge-graph.d.ts.map +1 -1
  87. package/dist/knowledge-graph.js +24 -3
  88. package/dist/knowledge-graph.js.map +1 -1
  89. package/dist/mcp/THIRD_PARTY_NOTICES.txt +2 -2
  90. package/dist/mcp/server.js +1079 -90
  91. package/dist/mcp/server.js.map +3 -3
  92. package/dist/skills-manifest.json +49 -24
  93. package/dist/transports/cli/cli.d.ts.map +1 -1
  94. package/dist/transports/cli/cli.js +7702 -5764
  95. package/dist/transports/cli/cli.js.map +3 -3
  96. package/dist/transports/http/server.d.ts.map +1 -1
  97. package/dist/transports/http/server.js +38 -27
  98. package/dist/transports/http/server.js.map +1 -1
  99. package/dist/transports/mcp/handlers.d.ts +25 -5
  100. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  101. package/dist/transports/mcp/handlers.js +43 -5
  102. package/dist/transports/mcp/handlers.js.map +1 -1
  103. package/dist/transports/schemas.d.ts +4 -2
  104. package/dist/transports/schemas.d.ts.map +1 -1
  105. package/dist/transports/schemas.js +29 -3
  106. package/dist/transports/schemas.js.map +1 -1
  107. package/docs/platforms/README.md +1 -0
  108. package/docs/platforms/agent-messaging.md +22 -5
  109. package/package.json +17 -4
  110. package/scripts/check-plugin-hook-artifact.mjs +212 -0
  111. package/scripts/hooks/_generated/briefing-index.js +147 -0
  112. package/scripts/hooks/_generated/capture-liveness.js +454 -0
  113. package/scripts/hooks/_generated/update-notice.js +195 -0
  114. package/scripts/hooks/_shared.js +302 -14
  115. package/scripts/hooks/_stop-notes.js +504 -0
  116. package/scripts/hooks/auto-update-runner.mjs +22 -0
  117. package/scripts/hooks/decision-nudge.js +32 -5
  118. package/scripts/hooks/guard-check.js +30 -3
  119. package/scripts/hooks/post-commit.js +329 -133
  120. package/scripts/hooks/pre-compact.js +17 -2
  121. package/scripts/hooks/pre-edit-recall.js +32 -1
  122. package/scripts/hooks/session-start.js +361 -78
  123. package/scripts/hooks/session-summary.js +217 -41
  124. package/scripts/hooks/user-prompt-intent.js +89 -19
  125. package/scripts/lib/npm-bin.mjs +123 -0
  126. package/scripts/upgrade-plugin.sh +24 -0
  127. package/skills/memesh/SKILL.md +20 -8
  128. package/dist/cli/assets/d3.v7.min.js +0 -2
  129. package/dist/core/graph.d.ts +0 -26
  130. package/dist/core/graph.d.ts.map +0 -1
  131. package/dist/core/graph.js +0 -74
  132. package/dist/core/graph.js.map +0 -1
@@ -1,5 +1,5 @@
1
- import { appendFileSync, chmodSync, closeSync, constants as fsConstants, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs';
2
- import { createHash } from 'crypto';
1
+ import { appendFileSync, chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'fs';
2
+ import { createHash, randomBytes } from 'crypto';
3
3
  import { spawn } from 'child_process';
4
4
  import { MemeshDatabase } from './_generated/sqlite.js';
5
5
  import { dirname, join } from 'path';
@@ -39,6 +39,39 @@ export { assembleTopologyBlock, buildReferenceContext, extractCitedMemoryIds, DE
39
39
  export { readRepoState, repoStateLines } from './_generated/repo-state.js';
40
40
  export { matchingGuards, guardFromMetadata } from './_generated/guards.js';
41
41
  export { writeCitationRule, citationRulePath, CITATION_RULE_BODY } from './_generated/citation-rule.js';
42
+ // Imported locally (recordHookOutcome below uses them) AND re-exported, so
43
+ // every hook reaches the same definition through one module.
44
+ import {
45
+ detectHookHost,
46
+ serializeHookOutcome,
47
+ trimHookOutcomeLines,
48
+ HOOK_OUTCOMES_FILENAME,
49
+ HOOK_OUTCOMES_ROTATE_BYTES,
50
+ } from './_generated/capture-liveness.js';
51
+ export {
52
+ advanceGraceState,
53
+ captureLivenessNotice,
54
+ captureLivenessVerdict,
55
+ graceInEffect,
56
+ parseGraceState,
57
+ parseHookOutcomes,
58
+ summarizeHookOutcomes,
59
+ HOOK_OUTCOMES_FILENAME,
60
+ SKIP_REASONS,
61
+ isGitCommitCommand,
62
+ } from './_generated/capture-liveness.js';
63
+ export {
64
+ resolveUpdateNotice,
65
+ shouldRefreshUpdateCache,
66
+ readSnooze,
67
+ writeSnooze,
68
+ clearSnooze,
69
+ readJustUpgradedMarker,
70
+ writeJustUpgradedMarker,
71
+ clearJustUpgradedMarker,
72
+ claimJustUpgradedMarker,
73
+ isStrictlyOlder,
74
+ } from './_generated/update-notice.js';
42
75
  import { guardFromMetadata as guardFromMetadataLocal } from './_generated/guards.js';
43
76
 
44
77
  /**
@@ -454,8 +487,10 @@ function migrateHookDbToCurrent(db, opts) {
454
487
  * BEFORE opening the database — and a correct nothing-to-do decision is a
455
488
  * successful run that must stamp, or a user whose sessions are consistently
456
489
  * short reads as "capture has stopped" in doctor within a day: the exact
457
- * crying-wolf this table exists to end. Stop fires once per session, so one
458
- * extra open+close here is noise.
490
+ * crying-wolf this table exists to end. Stop fires at the end of EVERY turn
491
+ * (#322), so this extra open+close happens once per bailed turn, not once
492
+ * per session — still cheap enough next to the transcript read the bail
493
+ * already did to skip.
459
494
  *
460
495
  * Never throws: the heartbeat is diagnostics, and the bail it decorates was
461
496
  * already a successful exit.
@@ -476,6 +511,154 @@ export function stampHookRunOnly(env, hook) {
476
511
  }
477
512
  }
478
513
 
514
+ const NOFOLLOW = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
515
+ const APPEND_NOFOLLOW_FLAGS = fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | NOFOLLOW;
516
+ const READ_NOFOLLOW_FLAGS = fsConstants.O_RDONLY | NOFOLLOW;
517
+
518
+ /**
519
+ * Record what `hook` DID, on every exit path (issue #327).
520
+ *
521
+ * `recordHookRun` answers "did the hook execute"; this answers "and did it
522
+ * write anything, and if not, why not". The gap between those two questions
523
+ * is where two days of an empty graph hid: post-commit was executing on every
524
+ * Bash call and skipping every one of them, because the commits were made
525
+ * with `-q` and printed no line to match. From the outside that is
526
+ * indistinguishable from a hook broken by an upgrade.
527
+ *
528
+ * Contract, in the same spirit as `stampHookRunOnly`:
529
+ * - NEVER throws. Diagnostics must not take capture down with them.
530
+ * - NEVER writes to stdout. The hook output contract is a single JSON
531
+ * document or nothing at all; one stray line breaks both hosts.
532
+ * - APPENDS one line (O_APPEND), never read-modify-write. SessionStart,
533
+ * UserPromptSubmit and a PreToolUse hook fire inside the same second on
534
+ * a busy turn: three processes reading the same JSON document and
535
+ * writing back what each of them read means the last one wins and the
536
+ * other two records are gone — the concurrency that proves a session is
537
+ * busy would be the concurrency that erases the proof. An append has no
538
+ * read step to lose, and the OS orders the writes.
539
+ * - Rotation (keep each hook's last 20 records) is the only rewrite, and it goes
540
+ * through temp + rename so a reader sees the old complete file or the
541
+ * new one.
542
+ *
543
+ * @param {Record<string,string|undefined>} env
544
+ * `outcome` has four kinds, and the line between the first two is the whole
545
+ * point of the record: `wrote` means a MEMORY was stored, and nothing else —
546
+ * it is the numerator of the signal `memesh doctor` uses to answer "is memory
547
+ * capture still alive". `notified` is for a hook whose effect is text the user
548
+ * or the model sees: an injected context, a printed warning, a nudge. Six
549
+ * hooks recorded those as `wrote`, each with its own comment saying it was not
550
+ * a memory, and the answer to that question was inflated by all six.
551
+ *
552
+ * @param {{hook: string, outcome: 'wrote'|'notified'|'skipped'|'error', reason?: string, entity?: string, payload?: object}} info
553
+ */
554
+ export function recordHookOutcome(env, { hook, outcome, reason, entity, payload }) {
555
+ try {
556
+ // getMemeshDirFromDbPath(), not memeshDir(): the record must sit beside
557
+ // the database it describes. A test (or a user) that points
558
+ // MEMESH_DB_PATH somewhere else would otherwise split the evidence — a
559
+ // graph in one directory, the liveness history of the hooks that filled
560
+ // it in another.
561
+ const dir = getMemeshDirFromDbPath();
562
+ ensurePrivateDir(dir);
563
+ const filePath = join(dir, HOOK_OUTCOMES_FILENAME);
564
+ const record = {
565
+ hook,
566
+ at: new Date().toISOString(),
567
+ host: detectHookHost(payload ?? null, env),
568
+ outcome,
569
+ };
570
+ // A hook's `reason` is, on the error path, the exception message — which
571
+ // may echo a credential a failed request or git command surfaced. Skip
572
+ // reasons are hard-coded literals and pass through unchanged, but the
573
+ // error ones are redacted before they persist: stderr is transient, this
574
+ // JSONL file is a permanent, exportable copy.
575
+ if (reason) record.reason = redactSecrets(String(reason)).slice(0, 200);
576
+ if (entity) record.entity = redactSecrets(String(entity)).slice(0, 200);
577
+ // One O_APPEND write of one line. `mode` applies only when the file is
578
+ // being created, which is the only moment the permission can be set
579
+ // without a second syscall on the hot path. O_NOFOLLOW: the directory
580
+ // can be a shared or repository path (MEMESH_DB_PATH), and a planted
581
+ // symlink named hook-outcomes.jsonl would otherwise turn every hook run
582
+ // into an append to a file of the planter's choosing. Windows has no
583
+ // O_NOFOLLOW (the constant is undefined there), so it contributes 0.
584
+ const fd = openSync(filePath, APPEND_NOFOLLOW_FLAGS, 0o600);
585
+ try {
586
+ writeSync(fd, serializeHookOutcome(record));
587
+ } finally {
588
+ closeSync(fd);
589
+ }
590
+ try { chmodSync(filePath, 0o600); } catch { /* best-effort hardening */ }
591
+ rotateHookOutcomes(filePath);
592
+ } catch (err) {
593
+ try {
594
+ process.stderr.write(
595
+ `MeMesh: could not record the ${hook} hook outcome (${err?.message ?? err}). ` +
596
+ `Capture itself is unaffected, but 'memesh doctor' will under-report capture liveness.\n`,
597
+ );
598
+ } catch { /* stderr gone */ }
599
+ }
600
+ }
601
+
602
+ /**
603
+ * The `reason` an outer catch may persist for an exception: its `code` or
604
+ * class name, never its message.
605
+ *
606
+ * A message is not a label, it is a copy of whatever the failure echoed: a
607
+ * V8 JSON parse error quotes the payload it choked on, an execFileSync error
608
+ * carries git's stderr and absolute paths. stderr is transient; the outcome
609
+ * file is permanent, exportable, and rendered into doctor and a pasted issue.
610
+ * So the file gets `uncaught SyntaxError` / `uncaught ENOENT`, and the full
611
+ * text goes to stderr where the hook already writes it.
612
+ *
613
+ * @param {unknown} err
614
+ * @returns {string}
615
+ */
616
+ export function hookErrorReason(err) {
617
+ const label = (value) => (typeof value === 'string' && /^[A-Za-z][\w-]{0,39}$/.test(value) ? value : null);
618
+ const e = err && typeof err === 'object' ? err : null;
619
+ return `uncaught ${label(e?.code) ?? label(e?.name) ?? 'error'}`;
620
+ }
621
+
622
+ /**
623
+ * Keep the history bounded, without paying a read on every append.
624
+ *
625
+ * A line count would mean reading the file back on the hot path — the read
626
+ * step O_APPEND exists to remove. A `stat` is cheap, so size is the trigger
627
+ * and the trim is exact. The rewrite goes through temp + rename: a
628
+ * concurrent appender may lose ONE line to the swap, which is why the byte
629
+ * budget is far larger than the window any summary reads.
630
+ */
631
+ function rotateHookOutcomes(filePath) {
632
+ let tmpPath = null;
633
+ try {
634
+ // One descriptor for both the size check and the read, so the file that
635
+ // was measured is the file that is read (a stat-then-open pair can be
636
+ // swapped in between). The size check is the hot path on every append
637
+ // and needs no random name.
638
+ let raw;
639
+ const fd = openSync(filePath, READ_NOFOLLOW_FLAGS);
640
+ try {
641
+ if (fstatSync(fd).size <= HOOK_OUTCOMES_ROTATE_BYTES) return;
642
+ raw = readFileSync(fd, 'utf8');
643
+ } finally {
644
+ closeSync(fd);
645
+ }
646
+ // An unpredictable name, created exclusively ('wx' = O_CREAT|O_EXCL,
647
+ // which refuses an existing path — a planted symlink included).
648
+ // `${pid}.tmp` was guessable, and the plain write followed whatever sat
649
+ // at that name.
650
+ tmpPath = `${filePath}.${randomBytes(8).toString('hex')}.tmp`;
651
+ const trimmed = trimHookOutcomeLines(raw);
652
+ writeFileSync(tmpPath, trimmed, { encoding: 'utf8', mode: PRIVATE_FILE_MODE, flag: 'wx' });
653
+ renameSync(tmpPath, filePath);
654
+ } catch (err) {
655
+ try { if (tmpPath && existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
656
+ try {
657
+ process.stderr.write(`[memesh hook-outcomes] rotation failed for ${filePath}: ${err?.message ?? err}\n`);
658
+ } catch { /* stderr gone */ }
659
+ }
660
+ }
661
+
479
662
  /**
480
663
  * Record that `hook` ran, right now.
481
664
  *
@@ -560,14 +743,20 @@ export { truncateTitle } from './_generated/title.js';
560
743
  * hooks stay a cheap always-on capture path and core owns later enrichment.
561
744
  *
562
745
  * @param {import('./_generated/sqlite.js').MemeshDatabase} db - an open hook DB handle
563
- * @param {{name: string, type: string, observations?: string[], tags?: string[], title?: string | null, metadata?: Record<string, unknown>}} entity
746
+ * @param {{name: string, type: string, observations?: string[], tags?: string[], title?: string | null, metadata?: Record<string, unknown>, replace?: boolean}} entity
564
747
  * `metadata` is extra INSERT-only metadata (e.g. post-commit's session_id +
565
748
  * files). It cannot override the provenance/title_source stamps below, and
566
749
  * an OR IGNORE re-capture of an existing entity leaves it untouched — same
567
- * first-writer-wins rule provenance already follows.
568
- * @returns {{ id: number, isNew: boolean } | null} null if the row could not be resolved
750
+ * first-writer-wins rule provenance already follows. `replace` (#322)
751
+ * restates the entity's observations and tags instead of adding to them
752
+ * for a caller whose entity is a per-turn SNAPSHOT, not an accumulating log.
753
+ * Unlike `remember({ replace: true })` in core, this is a HARD delete: no
754
+ * `replaced_history` is kept (see the comment at the DELETE below for why).
755
+ * @returns {{ id: number, isNew: boolean, archived?: true } | null} null if the row
756
+ * could not be resolved; `archived: true` if `replace` was requested on an
757
+ * entity `forget` archived — nothing was written, by design
569
758
  */
570
- export function captureEntity(db, { name, type, observations = [], tags = [], title, metadata }) {
759
+ export function captureEntity(db, { name, type, observations = [], tags = [], title, metadata, replace = false }) {
571
760
  // One transaction, because this function performs six writes that only
572
761
  // mean anything together: the entity row, its observations, its tags, and
573
762
  // the contentless-FTS delete + insert that make them findable.
@@ -586,10 +775,10 @@ export function captureEntity(db, { name, type, observations = [], tags = [], ti
586
775
  // could not be resolved. `observationsWritten` may be lower than
587
776
  // `observations.length`: an observation whose exact content is already on
588
777
  // the entity is not stored again (see the dedupe in captureEntityInner).
589
- return db.transaction(() => captureEntityInner(db, { name, type, observations, tags, title, metadata }))();
778
+ return db.transaction(() => captureEntityInner(db, { name, type, observations, tags, title, metadata, replace }))();
590
779
  }
591
780
 
592
- function captureEntityInner(db, { name, type, observations, tags, title, metadata }) {
781
+ function captureEntityInner(db, { name, type, observations, tags, title, metadata, replace }) {
593
782
  // source_host provenance: these hooks only ever run under Claude Code (they
594
783
  // are wired into ~/.claude/settings.json), so a hook-captured entity is by
595
784
  // definition a claude-code capture. Stamped only on the INSERT — an OR
@@ -606,10 +795,36 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
606
795
  .prepare('INSERT OR IGNORE INTO entities (name, type, metadata, title) VALUES (?, ?, ?, ?)')
607
796
  .run(name, type, JSON.stringify(insertMetadata), title ?? null);
608
797
  const isNew = insertResult.changes > 0;
609
- const row = db.prepare('SELECT id, title FROM entities WHERE name = ?').get(name);
798
+ const row = db.prepare('SELECT id, title, status, metadata FROM entities WHERE name = ?').get(name);
610
799
  if (!row) return null;
611
800
  const id = row.id;
612
801
 
802
+ // `replace` never touches an archived entity. src/core/operations.ts's
803
+ // `remember({ replace: true })` REFUSES this case with a thrown error —
804
+ // right for a rare, interactive call the user reads the response of, but
805
+ // a hook must never throw (it would abort the OTHER two entities' writes
806
+ // this Stop, and crash risk is exactly what this file exists to avoid).
807
+ // So the hook path degrades to a silent no-op instead: the archived row,
808
+ // its observations and its FTS absence are all left exactly as `forget`
809
+ // left them.
810
+ //
811
+ // Without this, a whole-entity `forget` (archiveEntity: status flipped to
812
+ // 'archived', its row removed from entities_fts) would come undone on the
813
+ // next Stop — `replace` would overwrite the preserved observations with a
814
+ // fresh derivation from the transcript and reinsert the entity into
815
+ // entities_fts, un-hiding it from FTS keyword search even though its
816
+ // status stays 'archived' (recall's default query filters status='active',
817
+ // which caps but does not close that exposure). `removeFromFts` guards its
818
+ // own delete on a rowid COUNT, so calling it on an already-removed row is
819
+ // a safe no-op either way — this check is about not losing the user's
820
+ // forgotten content, not about a contentless-FTS5 delete failure.
821
+ //
822
+ // Observation-level corrections remain active and are filtered below;
823
+ // this branch preserves the separate whole-entity archive contract.
824
+ if (replace && !isNew && row.status === 'archived') {
825
+ return { id, isNew: false, archived: true };
826
+ }
827
+
613
828
  // Title update on an EXISTING entity — INSERT OR IGNORE never touches
614
829
  // `title` when the row already exists, so mirror knowledge-graph.ts's
615
830
  // createEntity(): only an explicit, actually-different value writes
@@ -655,6 +870,38 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
655
870
  // BY + the one join rule), via the generated fts-index copy.
656
871
  const prevObsText = isNew ? undefined : indexedObservationText(db, id);
657
872
 
873
+ // `replace`: the caller is restating the whole entity, not adding to it.
874
+ //
875
+ // Appending is right for a `commit-<sha>` or a `pre-compact-<id>`, where
876
+ // each capture is a new fact about the same subject. It is wrong for a
877
+ // session insight, whose three entities are a SNAPSHOT of one session: Stop
878
+ // fires at the end of every turn, so appending stored the same sentences
879
+ // over and over (measured: 56 observations, 16 unique) and the workaround —
880
+ // capture once, then skip — froze a two-day session's memory at its first
881
+ // turn (#322). Replacing is the third answer: the snapshot is rewritten, so
882
+ // it is neither duplicated nor stale.
883
+ //
884
+ // The old rows go AFTER `prevObsText` was read above, so the contentless-FTS
885
+ // delete still matches exactly what was indexed. The re-insert below must
886
+ // then leave that text out.
887
+ //
888
+ // This is a HARD delete — no history kept. That is a deliberate difference
889
+ // from `remember({ replace: true })` in src/core/operations.ts, which files
890
+ // the old text into `metadata.replaced_history` before overwriting: that
891
+ // path is a rare, user-invoked correction, where an audit trail is worth
892
+ // the bytes. This path fires on every Stop, every turn, for a session that
893
+ // can run for hours — keeping history here would mean growing metadata on
894
+ // every single turn for content nobody asks to undo.
895
+ if (replace && !isNew) {
896
+ db.prepare('DELETE FROM observations WHERE entity_id = ?').run(id);
897
+ // Tags get the same treatment, for the same reason: "restating the whole
898
+ // entity" was true for observations and FTS but not for tags until this
899
+ // line — a session-<id>-files entity that stopped mentioning file A kept
900
+ // answering `file:a.ts` lookups (pre-edit-recall's Strategy 1) for a
901
+ // snapshot that no longer said anything about that file.
902
+ db.prepare('DELETE FROM tags WHERE entity_id = ?').run(id);
903
+ }
904
+
658
905
  // Never store the same sentence twice on one entity (#240, widened).
659
906
  //
660
907
  // #240 was fixed in session-summary.js alone, with an EXISTENCE guard: "if
@@ -683,12 +930,18 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
683
930
  // the "database disk image is malformed" failure this file warns about
684
931
  // above. The `seen` set also collapses repeats WITHIN one call.
685
932
  const seen = new Set(
686
- isNew
933
+ // `|| replace`: the rows a plain SELECT would find here were just
934
+ // DELETEd above (same transaction), so this skips a query that would
935
+ // only ever come back empty — not a second dedup path.
936
+ isNew || replace
687
937
  ? []
688
938
  : db.prepare('SELECT content FROM observations WHERE entity_id = ?').all(id).map((r) => r.content),
689
939
  );
690
940
  const freshObservations = [];
941
+ const forgotten = parseEntityMetadata(row.metadata)?.forgotten_observation_hashes;
942
+ const excluded = new Set(replace && Array.isArray(forgotten) ? forgotten : []);
691
943
  for (const obs of observations) {
944
+ if (excluded.has(createHash('sha256').update(obs).digest('hex'))) continue;
692
945
  if (seen.has(obs)) continue;
693
946
  seen.add(obs);
694
947
  freshObservations.push(obs);
@@ -711,7 +964,9 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
711
964
  // Stop/PreCompact/PostToolUse capture, and the re-read grew with an
712
965
  // upserted entity's accumulated observation count.
713
966
  const obsParts = [];
714
- if (prevObsText) obsParts.push(prevObsText);
967
+ // Not after a `replace`: those rows were deleted above, and carrying their
968
+ // text forward would index words the entity no longer holds.
969
+ if (prevObsText && !replace) obsParts.push(prevObsText);
715
970
  if (freshObservations.length) obsParts.push(joinIndexedObservations(freshObservations));
716
971
  const allObsText = joinIndexedObservations(obsParts);
717
972
  // Current title is fully determined by the branches above — no re-read.
@@ -903,6 +1158,23 @@ export function finalizeUpdatePromptClaim(sessionId, currentVersion, latestVersi
903
1158
  }
904
1159
  }
905
1160
 
1161
+ /**
1162
+ * The owner answered this session's notice. After this, further words in the
1163
+ * same session ("no" to an unrelated question, a stray "never") are not
1164
+ * decisions about updates. Returns false when there was no claim to mark.
1165
+ */
1166
+ export function markUpdatePromptAnswered(sessionId, currentVersion, latestVersion, decision) {
1167
+ const path = updatePromptClaimPath(sessionId, currentVersion, latestVersion);
1168
+ if (!path || !existsSync(path)) return false;
1169
+ try {
1170
+ const value = JSON.parse(readFileSync(path, 'utf8'));
1171
+ writePrivateJson(path, { ...value, decision: 'answered', answer: decision, answeredAt: new Date().toISOString() });
1172
+ return true;
1173
+ } catch {
1174
+ return false;
1175
+ }
1176
+ }
1177
+
906
1178
  export function readUpdatePromptClaim(sessionId, currentVersion, latestVersion) {
907
1179
  const path = updatePromptClaimPath(sessionId, currentVersion, latestVersion);
908
1180
  if (!path || !existsSync(path)) return null;
@@ -968,14 +1240,30 @@ export function writeAutoUpdateConsent(sessionId, currentVersion, latestVersion,
968
1240
  }
969
1241
  }
970
1242
 
1243
+ /**
1244
+ * Owner's answer to the first-use update notice.
1245
+ * 'approved' — install (npm-global only; other channels have no in-session installer)
1246
+ * 'declined' — "Not now": snooze this target (24h, then 48h, then 7d)
1247
+ * 'never' — "Never ask again": config.updateCheck = false
1248
+ */
971
1249
  export function parseAutoUpdateConsent(prompt) {
972
1250
  if (typeof prompt !== 'string') return null;
973
1251
  const value = prompt.trim().toLowerCase().replace(/[.!?。!?]+$/u, '');
1252
+ // "never" alone is NOT enough: it is an ordinary English word, and this
1253
+ // hook sees every prompt in a session that was shown the notice. The
1254
+ // phrases below are the ones the Settings hint tells users to type, in
1255
+ // each of the 11 dashboard locales.
1256
+ if (/^(?:never ask(?: me)?(?: again)?|don'?t ask(?: me)?(?: again)?|stop asking|不要再問|別再問|不再提醒|不要再问|今後は確認しない|다시 묻지 않기|não voltar a perguntar|ne plus demander|nicht mehr fragen|không hỏi lại|no volver a preguntar|ไม่ต้องถามอีก)$/.test(value)) return 'never';
974
1257
  if (/^(?:yes|y|upgrade|update|install(?: it)?|go ahead|是|好|升級|更新|安裝)$/.test(value)) return 'approved';
975
- if (/^(?:no|n|not now|later|不要|不用|稍後|暫時不要)$/.test(value)) return 'declined';
1258
+ if (/^(?:no|n|not now|later|不要|不用|稍後|暫時不要|稍后|後で|나중에|agora não|pas maintenant|jetzt nicht|để sau|ahora no|ไว้ก่อน)$/.test(value)) return 'declined';
976
1259
  return null;
977
1260
  }
978
1261
 
1262
+ /** `config.updateCheck` — false means the owner said never ask again. */
1263
+ export function isUpdateCheckEnabled(env = process.env) {
1264
+ return readHookConfig(env).updateCheck !== false;
1265
+ }
1266
+
979
1267
  export function decideAutoUpdateHook(currentVersion, cache, policy) {
980
1268
  if (!cache || cache.currentVersion !== currentVersion) return { run: false };
981
1269
  const latest = cache.latestVersion;