@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +35 -4
- package/README.de.md +3 -3
- package/README.md +3 -3
- package/README.zh-TW.md +3 -3
- package/dashboard/dist/index.html +8 -8
- package/dist/cli/view-live.d.ts.map +1 -1
- package/dist/cli/view-live.js +0 -427
- package/dist/cli/view-live.js.map +1 -1
- package/dist/core/briefing-index.d.ts +30 -0
- package/dist/core/briefing-index.d.ts.map +1 -0
- package/dist/core/briefing-index.js +140 -0
- package/dist/core/briefing-index.js.map +1 -0
- package/dist/core/briefing.d.ts +4 -0
- package/dist/core/briefing.d.ts.map +1 -1
- package/dist/core/briefing.js +42 -4
- package/dist/core/briefing.js.map +1 -1
- package/dist/core/capture-liveness.d.ts +136 -0
- package/dist/core/capture-liveness.d.ts.map +1 -0
- package/dist/core/capture-liveness.js +447 -0
- package/dist/core/capture-liveness.js.map +1 -0
- package/dist/core/config.d.ts +1 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +3 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/delegation.d.ts +47 -0
- package/dist/core/delegation.d.ts.map +1 -0
- package/dist/core/delegation.js +174 -0
- package/dist/core/delegation.js.map +1 -0
- package/dist/core/doctor-fixes.d.ts +1 -0
- package/dist/core/doctor-fixes.d.ts.map +1 -1
- package/dist/core/doctor-fixes.js +21 -0
- package/dist/core/doctor-fixes.js.map +1 -1
- package/dist/core/doctor.d.ts +8 -0
- package/dist/core/doctor.d.ts.map +1 -1
- package/dist/core/doctor.js +148 -3
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/note-derive.d.ts +14 -0
- package/dist/core/note-derive.d.ts.map +1 -0
- package/dist/core/note-derive.js +66 -0
- package/dist/core/note-derive.js.map +1 -0
- package/dist/core/note-ingest.d.ts +34 -0
- package/dist/core/note-ingest.d.ts.map +1 -0
- package/dist/core/note-ingest.js +445 -0
- package/dist/core/note-ingest.js.map +1 -0
- package/dist/core/operations.d.ts +10 -1
- package/dist/core/operations.d.ts.map +1 -1
- package/dist/core/operations.js +129 -14
- package/dist/core/operations.js.map +1 -1
- package/dist/core/paths.d.ts.map +1 -1
- package/dist/core/paths.js.map +1 -1
- package/dist/core/schema-export.d.ts.map +1 -1
- package/dist/core/schema-export.js +10 -4
- package/dist/core/schema-export.js.map +1 -1
- package/dist/core/serializer.d.ts.map +1 -1
- package/dist/core/serializer.js +3 -0
- package/dist/core/serializer.js.map +1 -1
- package/dist/core/session-insight.d.ts +44 -0
- package/dist/core/session-insight.d.ts.map +1 -0
- package/dist/core/session-insight.js +216 -0
- package/dist/core/session-insight.js.map +1 -0
- package/dist/core/task-state-store.d.ts +4 -0
- package/dist/core/task-state-store.d.ts.map +1 -1
- package/dist/core/task-state-store.js +18 -5
- package/dist/core/task-state-store.js.map +1 -1
- package/dist/core/turn-signal.d.ts +22 -0
- package/dist/core/turn-signal.d.ts.map +1 -0
- package/dist/core/turn-signal.js +89 -0
- package/dist/core/turn-signal.js.map +1 -0
- package/dist/core/types.d.ts +12 -3
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/update-entrypoint.d.ts +19 -0
- package/dist/core/update-entrypoint.d.ts.map +1 -0
- package/dist/core/update-entrypoint.js +222 -0
- package/dist/core/update-entrypoint.js.map +1 -0
- package/dist/core/update-notice.d.ts +69 -0
- package/dist/core/update-notice.d.ts.map +1 -0
- package/dist/core/update-notice.js +188 -0
- package/dist/core/update-notice.js.map +1 -0
- package/dist/host-runtime/codex-session.d.ts +10 -0
- package/dist/host-runtime/codex-session.d.ts.map +1 -1
- package/dist/host-runtime/codex-session.js +1 -1
- package/dist/host-runtime/codex-session.js.map +1 -1
- package/dist/knowledge-graph.d.ts.map +1 -1
- package/dist/knowledge-graph.js +24 -3
- package/dist/knowledge-graph.js.map +1 -1
- package/dist/mcp/THIRD_PARTY_NOTICES.txt +2 -2
- package/dist/mcp/server.js +1079 -90
- package/dist/mcp/server.js.map +3 -3
- package/dist/skills-manifest.json +49 -24
- package/dist/transports/cli/cli.d.ts.map +1 -1
- package/dist/transports/cli/cli.js +7702 -5764
- package/dist/transports/cli/cli.js.map +3 -3
- package/dist/transports/http/server.d.ts.map +1 -1
- package/dist/transports/http/server.js +38 -27
- package/dist/transports/http/server.js.map +1 -1
- package/dist/transports/mcp/handlers.d.ts +25 -5
- package/dist/transports/mcp/handlers.d.ts.map +1 -1
- package/dist/transports/mcp/handlers.js +43 -5
- package/dist/transports/mcp/handlers.js.map +1 -1
- package/dist/transports/schemas.d.ts +4 -2
- package/dist/transports/schemas.d.ts.map +1 -1
- package/dist/transports/schemas.js +29 -3
- package/dist/transports/schemas.js.map +1 -1
- package/docs/platforms/README.md +1 -0
- package/docs/platforms/agent-messaging.md +22 -5
- package/package.json +17 -4
- package/scripts/check-plugin-hook-artifact.mjs +212 -0
- package/scripts/hooks/_generated/briefing-index.js +147 -0
- package/scripts/hooks/_generated/capture-liveness.js +454 -0
- package/scripts/hooks/_generated/update-notice.js +195 -0
- package/scripts/hooks/_shared.js +302 -14
- package/scripts/hooks/_stop-notes.js +504 -0
- package/scripts/hooks/auto-update-runner.mjs +22 -0
- package/scripts/hooks/decision-nudge.js +32 -5
- package/scripts/hooks/guard-check.js +30 -3
- package/scripts/hooks/post-commit.js +329 -133
- package/scripts/hooks/pre-compact.js +17 -2
- package/scripts/hooks/pre-edit-recall.js +32 -1
- package/scripts/hooks/session-start.js +361 -78
- package/scripts/hooks/session-summary.js +217 -41
- package/scripts/hooks/user-prompt-intent.js +89 -19
- package/scripts/lib/npm-bin.mjs +123 -0
- package/scripts/upgrade-plugin.sh +24 -0
- package/skills/memesh/SKILL.md +20 -8
- package/dist/cli/assets/d3.v7.min.js +0 -2
- package/dist/core/graph.d.ts +0 -26
- package/dist/core/graph.d.ts.map +0 -1
- package/dist/core/graph.js +0 -74
- package/dist/core/graph.js.map +0 -1
package/scripts/hooks/_shared.js
CHANGED
|
@@ -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
|
|
458
|
-
* extra open+close
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|