hippo-memory 1.30.0 → 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts +49 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +78 -2
- package/dist/api.js.map +1 -1
- package/dist/audit.d.ts +1 -1
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js.map +1 -1
- package/dist/capture.d.ts.map +1 -1
- package/dist/capture.js +79 -23
- package/dist/capture.js.map +1 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +233 -13
- package/dist/cli.js.map +1 -1
- package/dist/connectors/github/ingest.d.ts.map +1 -1
- package/dist/connectors/github/ingest.js +17 -0
- package/dist/connectors/github/ingest.js.map +1 -1
- package/dist/connectors/slack/ingest.d.ts.map +1 -1
- package/dist/connectors/slack/ingest.js +15 -0
- package/dist/connectors/slack/ingest.js.map +1 -1
- package/dist/consolidate.d.ts +1 -0
- package/dist/consolidate.d.ts.map +1 -1
- package/dist/consolidate.js +471 -291
- package/dist/consolidate.js.map +1 -1
- package/dist/dag.d.ts +17 -1
- package/dist/dag.d.ts.map +1 -1
- package/dist/dag.js +115 -40
- package/dist/dag.js.map +1 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +43 -1
- package/dist/db.js.map +1 -1
- package/dist/extract.d.ts.map +1 -1
- package/dist/extract.js +24 -1
- package/dist/extract.js.map +1 -1
- package/dist/importers.d.ts +10 -0
- package/dist/importers.d.ts.map +1 -1
- package/dist/importers.js +242 -159
- package/dist/importers.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +58 -24
- package/dist/mcp/server.js.map +1 -1
- package/dist/reject-flow.d.ts +66 -0
- package/dist/reject-flow.d.ts.map +1 -0
- package/dist/reject-flow.js +207 -0
- package/dist/reject-flow.js.map +1 -0
- package/dist/rejection.d.ts +100 -0
- package/dist/rejection.d.ts.map +1 -0
- package/dist/rejection.js +157 -0
- package/dist/rejection.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +4 -0
- package/dist/server.js.map +1 -1
- package/dist/shared.d.ts +9 -0
- package/dist/shared.d.ts.map +1 -1
- package/dist/shared.js +57 -6
- package/dist/shared.js.map +1 -1
- package/dist/sleep-redact.d.ts +2 -0
- package/dist/sleep-redact.d.ts.map +1 -1
- package/dist/sleep-redact.js +2 -0
- package/dist/sleep-redact.js.map +1 -1
- package/dist/src/api.js +78 -2
- package/dist/src/api.js.map +1 -1
- package/dist/src/audit.js.map +1 -1
- package/dist/src/capture.js +79 -23
- package/dist/src/capture.js.map +1 -1
- package/dist/src/cli.js +233 -13
- package/dist/src/cli.js.map +1 -1
- package/dist/src/connectors/github/ingest.js +17 -0
- package/dist/src/connectors/github/ingest.js.map +1 -1
- package/dist/src/connectors/slack/ingest.js +15 -0
- package/dist/src/connectors/slack/ingest.js.map +1 -1
- package/dist/src/consolidate.js +471 -291
- package/dist/src/consolidate.js.map +1 -1
- package/dist/src/dag.js +115 -40
- package/dist/src/dag.js.map +1 -1
- package/dist/src/db.js +43 -1
- package/dist/src/db.js.map +1 -1
- package/dist/src/extract.js +24 -1
- package/dist/src/extract.js.map +1 -1
- package/dist/src/importers.js +242 -159
- package/dist/src/importers.js.map +1 -1
- package/dist/src/mcp/server.js +58 -24
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/reject-flow.js +207 -0
- package/dist/src/reject-flow.js.map +1 -0
- package/dist/src/rejection.js +157 -0
- package/dist/src/rejection.js.map +1 -0
- package/dist/src/server.js +4 -0
- package/dist/src/server.js.map +1 -1
- package/dist/src/shared.js +57 -6
- package/dist/src/shared.js.map +1 -1
- package/dist/src/sleep-redact.js +2 -0
- package/dist/src/sleep-redact.js.map +1 -1
- package/dist/src/store.js +511 -38
- package/dist/src/store.js.map +1 -1
- package/dist/src/version.js +1 -1
- package/dist/store.d.ts +116 -6
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +511 -38
- package/dist/store.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -14,6 +14,13 @@ import { tokenize } from './search.js';
|
|
|
14
14
|
import { appendAuditEvent } from './audit.js';
|
|
15
15
|
import { resolveTenantId } from './tenant.js';
|
|
16
16
|
import { deriveOriginProject, originFromSource } from './project-identity.js';
|
|
17
|
+
import { checkRejectionGuard, RejectedValueError, rejectionDigest, normalizeValueForRejection, insertRejectedValue, findRejectedValue, } from './rejection.js';
|
|
18
|
+
// AT1 (plan §5): resolveConflict's kind-aware loser removal needs
|
|
19
|
+
// archiveRawMemory for kind='raw' losers. raw-archive.ts imports
|
|
20
|
+
// markSummaryDirtyInTx from this module — both imports are used only
|
|
21
|
+
// inside function bodies (never at module-evaluation time), so the cycle
|
|
22
|
+
// is the standard safe mutual-function-reference shape under NodeNext ESM.
|
|
23
|
+
import { archiveRawMemory } from './raw-archive.js';
|
|
17
24
|
/**
|
|
18
25
|
* Emit an audit event for a mutation against `db`. Wrapped so a broken audit
|
|
19
26
|
* log can never crash the surrounding mutation — the SQLite store is still the
|
|
@@ -34,6 +41,16 @@ function audit(db, op, targetId, metadata, actor = 'cli', tenantId) {
|
|
|
34
41
|
// table is broken; the mutation has already succeeded.
|
|
35
42
|
}
|
|
36
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Refusal audit for the AT1 rejection guard (plan §3). Written by the
|
|
46
|
+
* transaction OWNER post-rollback — writeEntry's catch (no outer tx exists
|
|
47
|
+
* there, so this lands in a fresh implicit transaction) and api.supersede's
|
|
48
|
+
* catch (after its own ROLLBACK) — never inside a scope the caller's own
|
|
49
|
+
* rollback could claw back. Best-effort `audit()` semantics: never throws.
|
|
50
|
+
*/
|
|
51
|
+
export function auditRejectionRefusal(db, err, actor) {
|
|
52
|
+
audit(db, 'reject_refusal', err.entryId, { digest: err.digest, reason: err.reason }, actor, err.tenantId);
|
|
53
|
+
}
|
|
37
54
|
const INDEX_VERSION = 3;
|
|
38
55
|
const MEMORY_SELECT_COLUMNS = `id, created, last_retrieved, retrieval_count, strength, half_life_days, layer, tags_json, emotional_valence, schema_fit, source, outcome_score, outcome_positive, outcome_negative, conflicts_with_json, pinned, confidence, content, parents_json, starred, trace_outcome, source_session_id, valid_from, superseded_by, extracted_from, dag_level, dag_parent_id, kind, scope, owner, artifact_ref, tenant_id, origin_project, descendant_count, earliest_at, latest_at, summary_dirty, last_rebuilt_at, rebuild_count, dag_level_3_built_at`;
|
|
39
56
|
// F1 (v1.7.0): qualified-and-aliased columns for the FTS join in
|
|
@@ -630,28 +647,130 @@ function writeMarkdownMirror(hippoRoot, entry) {
|
|
|
630
647
|
fs.mkdirSync(dir, { recursive: true });
|
|
631
648
|
fs.writeFileSync(path.join(dir, `${entry.id}.md`), serializeEntry(entry), 'utf8');
|
|
632
649
|
}
|
|
650
|
+
// AT1 P1 fix (codex): `writeMarkdownMirror` writes ANY layer's mirror,
|
|
651
|
+
// including `trace/<id>.md` for Layer.Trace rows (auto-promoted traces,
|
|
652
|
+
// consolidate.ts) — but this enumeration only walked
|
|
653
|
+
// Buffer/Episodic/Semantic. A rejected/forgotten trace row's markdown
|
|
654
|
+
// content survived on disk while the purge (and `hippo reject`/plain
|
|
655
|
+
// `forget`) reported success, and a stale trace mirror is exactly the
|
|
656
|
+
// resurrection channel bootstrapLegacyStore/rebuildIndex guard against.
|
|
657
|
+
// Fixes BOTH the AT1 reject-flow purge and the pre-existing plain-`forget`
|
|
658
|
+
// gap for trace rows (deleteEntry has always called this same function).
|
|
633
659
|
export function removeEntryMirrors(hippoRoot, id) {
|
|
634
|
-
for (const layer of [Layer.Buffer, Layer.Episodic, Layer.Semantic]) {
|
|
660
|
+
for (const layer of [Layer.Buffer, Layer.Episodic, Layer.Semantic, Layer.Trace]) {
|
|
635
661
|
const file = path.join(layerDir(hippoRoot, layer), `${id}.md`);
|
|
636
662
|
if (fs.existsSync(file)) {
|
|
637
663
|
fs.unlinkSync(file);
|
|
638
664
|
}
|
|
639
665
|
}
|
|
640
666
|
}
|
|
667
|
+
/**
|
|
668
|
+
* AT1 mirror-purge honesty fix (docs/plans/2026-08-15-at1-rejected-value-tombstone.md):
|
|
669
|
+
* the candidate markdown mirror paths still on disk for `id`, computed the
|
|
670
|
+
* same way `removeEntryMirrors` walks them (one per layer: buffer/episodic/
|
|
671
|
+
* semantic), filtered to the ones that still `fs.existsSync`. Used to report
|
|
672
|
+
* an EXPLICIT path when a best-effort purge fails and no reaper exists to
|
|
673
|
+
* retry it — plain `removeEntryMirrors` returns void, giving no way to name
|
|
674
|
+
* which file is stuck.
|
|
675
|
+
*/
|
|
676
|
+
export function getExistingEntryMirrorPaths(hippoRoot, id) {
|
|
677
|
+
// AT1 P1 fix (codex): same missing Layer.Trace as removeEntryMirrors above
|
|
678
|
+
// — kept in lockstep with it since this function's whole purpose is
|
|
679
|
+
// walking the mirror paths "the same way removeEntryMirrors walks them"
|
|
680
|
+
// (see its own doc comment).
|
|
681
|
+
return [Layer.Buffer, Layer.Episodic, Layer.Semantic, Layer.Trace]
|
|
682
|
+
.map((layer) => path.join(layerDir(hippoRoot, layer), `${id}.md`))
|
|
683
|
+
.filter((file) => fs.existsSync(file));
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* AT1 fix: best-effort markdown-mirror purge shared by `reject-flow.ts`'s
|
|
687
|
+
* `rejectValue` and `resolveConflict`'s post-commit purge. Both used to log
|
|
688
|
+
* "will retry via reaper on next open" for EVERY failure, but the reaper
|
|
689
|
+
* (`cleanupArchivedMirrors`, raw-archive-mirror-cleanup.ts) only scans
|
|
690
|
+
* `raw_archive` — that message was false for a non-raw id, which has no
|
|
691
|
+
* reaper at all.
|
|
692
|
+
*
|
|
693
|
+
* Retries the unlink once synchronously (the common real-world failure is a
|
|
694
|
+
* transient lock/AV-scanner false positive, not a permanent one). On a
|
|
695
|
+
* second failure: raw ids still get the honest reaper message (true); non-raw
|
|
696
|
+
* ids get the EXPLICIT leftover file path(s) and a manual-delete instruction,
|
|
697
|
+
* since nothing will ever retry them automatically.
|
|
698
|
+
*
|
|
699
|
+
* Returns true if the mirror ended up purged (first or second attempt).
|
|
700
|
+
*/
|
|
701
|
+
export function purgeMirrorBestEffort(hippoRoot, id, isRaw, logPrefix) {
|
|
702
|
+
try {
|
|
703
|
+
removeEntryMirrors(hippoRoot, id);
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
try {
|
|
708
|
+
removeEntryMirrors(hippoRoot, id);
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
catch (secondErr) {
|
|
712
|
+
const msg = secondErr instanceof Error ? secondErr.message : String(secondErr);
|
|
713
|
+
if (isRaw) {
|
|
714
|
+
console.error(`${logPrefix}: mirror cleanup failed for ${id} (will retry via reaper on next open): ${msg}`);
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
const leftover = getExistingEntryMirrorPaths(hippoRoot, id);
|
|
718
|
+
const pathsNote = leftover.length > 0 ? leftover.join(', ') : `${id}.md (path unresolved)`;
|
|
719
|
+
console.error(`${logPrefix}: mirror cleanup failed for ${id} - no automatic retry exists for this file, ` +
|
|
720
|
+
`delete it manually: ${pathsNote} (${msg})`);
|
|
721
|
+
}
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
641
726
|
function bootstrapLegacyStore(db, hippoRoot) {
|
|
642
727
|
const countRow = db.prepare(`SELECT COUNT(*) AS count FROM memories`).get();
|
|
643
728
|
const memoryCount = Number(countRow?.count ?? 0);
|
|
644
729
|
if (memoryCount > 0)
|
|
645
730
|
return false;
|
|
731
|
+
// AT1 P2 fix: memoryCount alone is not a reliable "already bootstrapped"
|
|
732
|
+
// signal once the rejection guard exists. If EVERY legacy mirror row is
|
|
733
|
+
// rejected, memories stays at 0 rows even after a successful bootstrap
|
|
734
|
+
// pass, so the memoryCount>0 gate above never trips — every subsequent
|
|
735
|
+
// initStore() call would re-run this whole function: re-scan the legacy
|
|
736
|
+
// mirrors, re-attempt (and re-refuse, re-auditing) every row, and
|
|
737
|
+
// re-INSERT the legacy consolidation_runs rows with no dedup, duplicating
|
|
738
|
+
// them on each open. A dedicated meta flag marks bootstrap as
|
|
739
|
+
// attempted-and-settled regardless of how many rows actually landed.
|
|
740
|
+
if (getMeta(db, 'legacy_bootstrap_completed', '0') === '1')
|
|
741
|
+
return false;
|
|
646
742
|
const legacyEntries = loadLegacyEntriesFromMarkdown(hippoRoot);
|
|
647
743
|
if (legacyEntries.length === 0)
|
|
648
744
|
return false;
|
|
649
745
|
db.exec('BEGIN');
|
|
650
746
|
try {
|
|
747
|
+
// AT1 (plan §3, round-3 redesign): run the guard LIVE per row rather
|
|
748
|
+
// than bypassing it. bootstrapLegacyStore is exactly the channel through
|
|
749
|
+
// which a stale/never-purged markdown mirror could resurrect a rejected
|
|
750
|
+
// value; a skip-and-count here closes that structurally, independent of
|
|
751
|
+
// mirror state. The refusal audit is written INLINE inside this
|
|
752
|
+
// still-open loop transaction (plain audit() — nothing is rolled back
|
|
753
|
+
// on a per-row skip, so the post-rollback auditRejectionRefusal helper
|
|
754
|
+
// is the wrong tool here).
|
|
755
|
+
let rejectedCount = 0;
|
|
651
756
|
for (const entry of legacyEntries) {
|
|
652
757
|
// v39: legacy markdown carries no origin_project; stamp from the store
|
|
653
758
|
// location so bootstrapped rows stay visible to ambient context.
|
|
654
|
-
|
|
759
|
+
const stamped = stampOriginProjectForImport(hippoRoot, entry);
|
|
760
|
+
try {
|
|
761
|
+
upsertEntryRow(db, stamped);
|
|
762
|
+
}
|
|
763
|
+
catch (err) {
|
|
764
|
+
if (err instanceof RejectedValueError) {
|
|
765
|
+
rejectedCount++;
|
|
766
|
+
audit(db, 'reject_refusal', err.entryId, { digest: err.digest, reason: err.reason }, 'cli', err.tenantId);
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
throw err;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (rejectedCount > 0) {
|
|
773
|
+
console.error(`bootstrapLegacyStore: skipped ${rejectedCount} rejected value(s) found in legacy mirrors`);
|
|
655
774
|
}
|
|
656
775
|
const legacyIndex = loadLegacyIndexFile(hippoRoot);
|
|
657
776
|
setMeta(db, 'last_retrieval_ids', JSON.stringify(legacyIndex.last_retrieval_ids ?? []));
|
|
@@ -674,6 +793,9 @@ function bootstrapLegacyStore(db, hippoRoot) {
|
|
|
674
793
|
const row = run;
|
|
675
794
|
insertRun.run(String(row.timestamp ?? new Date().toISOString()), Number(row.decayed ?? 0), Number(row.merged ?? 0), Number(row.removed ?? 0));
|
|
676
795
|
}
|
|
796
|
+
// AT1 P2 fix: stamp completion regardless of how many rows actually
|
|
797
|
+
// landed (all-rejected included) — see the gate comment above.
|
|
798
|
+
setMeta(db, 'legacy_bootstrap_completed', '1');
|
|
677
799
|
db.exec('COMMIT');
|
|
678
800
|
}
|
|
679
801
|
catch (error) {
|
|
@@ -733,7 +855,32 @@ function loadLegacyStatsFile(hippoRoot) {
|
|
|
733
855
|
};
|
|
734
856
|
}
|
|
735
857
|
}
|
|
736
|
-
|
|
858
|
+
/**
|
|
859
|
+
* `bypassRejectionGuard` (AT1, plan §3): ONLY `batchWriteAndDelete`'s call
|
|
860
|
+
* site passes `true`. Consolidation merges are DETERMINISTIC CONCATENATION
|
|
861
|
+
* (mergeContents, consolidate.ts:736-751), not LLM paraphrase — the bypass
|
|
862
|
+
* is safe because the producer (consolidate.ts's merge pass) now checks the
|
|
863
|
+
* merged content's rejection digest against the tenant's tombstones BEFORE
|
|
864
|
+
* ever assembling a batch to write, and skips the merge entirely on a hit.
|
|
865
|
+
* Every other caller (writeEntryDbOnly, bootstrapLegacyStore, rebuildIndex)
|
|
866
|
+
* leaves this false and the guard runs live.
|
|
867
|
+
*
|
|
868
|
+
* AT1 P1 fix (codex, batch-transaction rejection race): the producer check
|
|
869
|
+
* above runs on a DIFFERENT connection BEFORE this transaction opens — a
|
|
870
|
+
* `hippo reject X` that commits in that window is invisible to it. This
|
|
871
|
+
* parameter's contract is UNCHANGED (still the sole bypass, still trusted
|
|
872
|
+
* by the producer-side check for the common case); what changed is that
|
|
873
|
+
* `batchWriteAndDelete` no longer trusts it BLINDLY. It now runs its own
|
|
874
|
+
* in-transaction point-probe (same connection, same digest lookup this
|
|
875
|
+
* function's guard would have done) immediately before each upsert and
|
|
876
|
+
* skips — rather than writes — any entry whose content matches a tombstone
|
|
877
|
+
* that landed after the producer's check. See batchWriteAndDelete for the
|
|
878
|
+
* skip logic.
|
|
879
|
+
*/
|
|
880
|
+
function upsertEntryRow(db, entry, bypassRejectionGuard = false) {
|
|
881
|
+
if (!bypassRejectionGuard) {
|
|
882
|
+
checkRejectionGuard(db, entry.tenantId ?? 'default', entry.id, entry.content);
|
|
883
|
+
}
|
|
737
884
|
db.prepare(`
|
|
738
885
|
INSERT INTO memories(
|
|
739
886
|
id, created, last_retrieved, retrieval_count, strength, half_life_days, layer,
|
|
@@ -813,7 +960,14 @@ function deleteFtsRow(db, id) {
|
|
|
813
960
|
// Best effort.
|
|
814
961
|
}
|
|
815
962
|
}
|
|
816
|
-
|
|
963
|
+
/**
|
|
964
|
+
* Derive the current `HippoIndex` (entries + last-retrieval/trace lockstep
|
|
965
|
+
* meta) from SQLite, the source of truth. Exported (AT1) for the same
|
|
966
|
+
* reason as `writeIndexMirror` below: `src/reject-flow.ts` needs to rebuild
|
|
967
|
+
* the index mirror post-commit after a (possibly multi-row) reject removal,
|
|
968
|
+
* without duplicating this query.
|
|
969
|
+
*/
|
|
970
|
+
export function buildIndexFromDb(db) {
|
|
817
971
|
const rows = db.prepare(`SELECT id, created, last_retrieved, strength, layer, tags_json, pinned FROM memories ORDER BY created ASC, id ASC`).all();
|
|
818
972
|
const entries = {};
|
|
819
973
|
for (const row of rows) {
|
|
@@ -853,7 +1007,14 @@ function buildStatsFromDb(db) {
|
|
|
853
1007
|
consolidation_runs: runs,
|
|
854
1008
|
};
|
|
855
1009
|
}
|
|
856
|
-
|
|
1010
|
+
/**
|
|
1011
|
+
* Write the `index.json` mirror file for a given (already-derived) index.
|
|
1012
|
+
* Exported (AT1) so `src/reject-flow.ts` can replicate `deleteEntry`'s exact
|
|
1013
|
+
* post-commit "removeEntryMirrors then rewrite the index once" sequence for
|
|
1014
|
+
* the reject verb's (possibly multi-row) removal, without duplicating
|
|
1015
|
+
* `buildIndexFromDb`'s query.
|
|
1016
|
+
*/
|
|
1017
|
+
export function writeIndexMirror(hippoRoot, index) {
|
|
857
1018
|
fs.writeFileSync(path.join(hippoRoot, 'index.json'), JSON.stringify(index, null, 2), 'utf8');
|
|
858
1019
|
}
|
|
859
1020
|
function writeStatsMirror(hippoRoot, stats) {
|
|
@@ -981,6 +1142,16 @@ export function writeEntry(hippoRoot, entry, opts) {
|
|
|
981
1142
|
opts?.afterCommit?.();
|
|
982
1143
|
writeEntryMirrors(hippoRoot, db, stamped);
|
|
983
1144
|
}
|
|
1145
|
+
catch (error) {
|
|
1146
|
+
// AT1 (plan §3): writeEntryDbOnly's own SAVEPOINT has already unwound by
|
|
1147
|
+
// the time this catch runs, so the refusal audit lands post-rollback in
|
|
1148
|
+
// a fresh implicit transaction — then rethrow so the caller sees the
|
|
1149
|
+
// refusal.
|
|
1150
|
+
if (error instanceof RejectedValueError) {
|
|
1151
|
+
auditRejectionRefusal(db, error, opts?.actor ?? 'cli');
|
|
1152
|
+
}
|
|
1153
|
+
throw error;
|
|
1154
|
+
}
|
|
984
1155
|
finally {
|
|
985
1156
|
closeHippoDb(db);
|
|
986
1157
|
}
|
|
@@ -1246,35 +1417,66 @@ export function loadChildrenOf(hippoRoot, parentId, tenantId) {
|
|
|
1246
1417
|
closeHippoDb(db);
|
|
1247
1418
|
}
|
|
1248
1419
|
}
|
|
1420
|
+
/**
|
|
1421
|
+
* AT1 (plan §4, round-2 fix, designed from source): db-scoped delete core.
|
|
1422
|
+
* `deleteEntry` used to open+close its OWN connection, which meant it could
|
|
1423
|
+
* never compose inside a caller's transaction (unlike writeEntry/
|
|
1424
|
+
* writeEntryDbOnly, which already split this way). Split identically: row-
|
|
1425
|
+
* meta SELECT, `DELETE FROM memories`, FTS delete, `forget` audit, DAG
|
|
1426
|
+
* dirty-mark. NO filesystem I/O — the caller's own transaction may still be
|
|
1427
|
+
* rolled back, and mirror writes must only happen post-commit.
|
|
1428
|
+
*
|
|
1429
|
+
* `opts.suppressForgetAudit` (default false, off): two AT1 callers set this
|
|
1430
|
+
* so a removed non-raw row does NOT ALSO emit a `forget` row, because each
|
|
1431
|
+
* already writes its own aggregate audit trail — `src/reject-flow.ts`'s
|
|
1432
|
+
* `rejectValue` (single `reject_value` row covering every same-digest row
|
|
1433
|
+
* removed) and `resolveConflict` (`conflict_resolve` row per resolution).
|
|
1434
|
+
* Default keeps `deleteEntry` byte-identical to its pre-split behavior.
|
|
1435
|
+
*
|
|
1436
|
+
* Returns `{tenantId, dagParentId}` for the removed row, or `null` if no row
|
|
1437
|
+
* with `id` existed.
|
|
1438
|
+
*/
|
|
1439
|
+
export function deleteEntryCore(db, id, opts) {
|
|
1440
|
+
const row = db
|
|
1441
|
+
.prepare(`SELECT id, tenant_id, dag_parent_id FROM memories WHERE id = ?`)
|
|
1442
|
+
.get(id);
|
|
1443
|
+
if (!row?.id)
|
|
1444
|
+
return null;
|
|
1445
|
+
db.prepare(`DELETE FROM memories WHERE id = ?`).run(id);
|
|
1446
|
+
deleteFtsRow(db, id);
|
|
1447
|
+
if (!opts?.suppressForgetAudit) {
|
|
1448
|
+
audit(db, 'forget', id, undefined, opts?.actor ?? 'cli', row.tenant_id);
|
|
1449
|
+
}
|
|
1450
|
+
// v0.30 / E2 — DAG live-coupling: forget of a child under a level-2
|
|
1451
|
+
// summary marks parent dirty. Non-atomic with the DELETE (no SAVEPOINT
|
|
1452
|
+
// wrapper here, same as pre-split deleteEntry); markSummaryDirtyInTx is
|
|
1453
|
+
// idempotent so any future child mutation re-marks parent if this fails.
|
|
1454
|
+
// Acceptable degradation, mirrors the pre-split audit best-effort posture.
|
|
1455
|
+
if (row.dag_parent_id) {
|
|
1456
|
+
markSummaryDirtyInTx(db, row.dag_parent_id, row.tenant_id ?? 'default', opts?.actor ?? 'cli');
|
|
1457
|
+
}
|
|
1458
|
+
return { tenantId: row.tenant_id ?? 'default', dagParentId: row.dag_parent_id ?? null };
|
|
1459
|
+
}
|
|
1249
1460
|
/**
|
|
1250
1461
|
* Delete an entry from SQLite and mirrors.
|
|
1251
1462
|
*
|
|
1252
1463
|
* `opts.actor` defaults to 'cli'. The api.* layer threads `ctx.actor` so HTTP
|
|
1253
1464
|
* callers land with `api_key:<key_id>` in the audit log without a duplicate
|
|
1254
1465
|
* emit from the api wrapper.
|
|
1466
|
+
*
|
|
1467
|
+
* Thin wrapper over `deleteEntryCore` (open → core → mirrors → close);
|
|
1468
|
+
* behavior is byte-identical to the pre-split implementation for every
|
|
1469
|
+
* existing caller.
|
|
1255
1470
|
*/
|
|
1256
1471
|
export function deleteEntry(hippoRoot, id, opts) {
|
|
1257
1472
|
initStore(hippoRoot);
|
|
1258
1473
|
const db = openHippoDb(hippoRoot);
|
|
1259
1474
|
try {
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1262
|
-
.get(id);
|
|
1263
|
-
if (!row?.id)
|
|
1475
|
+
const result = deleteEntryCore(db, id, opts);
|
|
1476
|
+
if (!result)
|
|
1264
1477
|
return false;
|
|
1265
|
-
db.prepare(`DELETE FROM memories WHERE id = ?`).run(id);
|
|
1266
|
-
deleteFtsRow(db, id);
|
|
1267
1478
|
removeEntryMirrors(hippoRoot, id);
|
|
1268
1479
|
writeIndexMirror(hippoRoot, buildIndexFromDb(db));
|
|
1269
|
-
audit(db, 'forget', id, undefined, opts?.actor ?? 'cli', row.tenant_id);
|
|
1270
|
-
// v0.30 / E2 — DAG live-coupling: forget of a child under a level-2
|
|
1271
|
-
// summary marks parent dirty. Non-atomic with the DELETE (deleteEntry
|
|
1272
|
-
// has no SAVEPOINT wrapper); markSummaryDirtyInTx is idempotent so any
|
|
1273
|
-
// future child mutation re-marks parent if this fails. Acceptable
|
|
1274
|
-
// degradation, mirrors deleteEntry's existing audit best-effort posture.
|
|
1275
|
-
if (row.dag_parent_id) {
|
|
1276
|
-
markSummaryDirtyInTx(db, row.dag_parent_id, row.tenant_id ?? 'default', opts?.actor ?? 'cli');
|
|
1277
|
-
}
|
|
1278
1480
|
return true;
|
|
1279
1481
|
}
|
|
1280
1482
|
finally {
|
|
@@ -1291,7 +1493,14 @@ export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds) {
|
|
|
1291
1493
|
initStore(hippoRoot);
|
|
1292
1494
|
const db = openHippoDb(hippoRoot);
|
|
1293
1495
|
try {
|
|
1294
|
-
|
|
1496
|
+
// BEGIN IMMEDIATE (codex delta-review P2): the AT1 tombstone probes below
|
|
1497
|
+
// READ before the first write. Under a deferred BEGIN, that read pins a
|
|
1498
|
+
// WAL snapshot; a concurrent writer (e.g. `hippo reject`) committing
|
|
1499
|
+
// between probe and first upsert would make the later write-lock upgrade
|
|
1500
|
+
// fail with SQLITE_BUSY and roll back the ENTIRE batch — the exact race
|
|
1501
|
+
// the probe exists to contain. Taking the write lock up front serializes
|
|
1502
|
+
// the probe and the writes on one consistent snapshot.
|
|
1503
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1295
1504
|
// v0.30 / E2 — DAG live-coupling: BEFORE deletes, snapshot dag_parent_id
|
|
1296
1505
|
// for every doomed row so we can mark parents dirty post-COMMIT. Done
|
|
1297
1506
|
// inside the same BEGIN so the SELECT sees pre-delete state.
|
|
@@ -1315,8 +1524,61 @@ export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds) {
|
|
|
1315
1524
|
// origin would make freshly consolidated memories vanish from ambient
|
|
1316
1525
|
// context (codex gating review P1).
|
|
1317
1526
|
const stampedWrites = toWrite.map((e) => stampOriginProject(hippoRoot, e));
|
|
1527
|
+
// AT1 P1 fix (codex, batch-transaction rejection race): the producer-side
|
|
1528
|
+
// check (e.g. consolidate.ts's merge pass) runs BEFORE this transaction,
|
|
1529
|
+
// on a different connection. A `hippo reject X` that commits in that
|
|
1530
|
+
// window is invisible to it — a queued same-id write of X already
|
|
1531
|
+
// sitting in `toWrite` (decay/replay re-persist, or a merge built before
|
|
1532
|
+
// the reject) would silently re-INSERT the just-rejected row via the
|
|
1533
|
+
// blind bypass. Fix: one indexed point probe per batch entry, on THIS
|
|
1534
|
+
// connection, INSIDE this transaction — closes the race regardless of
|
|
1535
|
+
// which write class hits it. N is small per sleep, so the extra query
|
|
1536
|
+
// per entry is cheap.
|
|
1537
|
+
//
|
|
1538
|
+
// Skip, don't throw: the batch must still complete for every OTHER
|
|
1539
|
+
// entry. Skipping is correct for every write class here — a merge
|
|
1540
|
+
// summary skip just means that rollup is absent this cycle (its source
|
|
1541
|
+
// facts stay merely demoted, recoverable next sleep); a skipped
|
|
1542
|
+
// demotion/replay re-persist of a rejected-removed row means it stays
|
|
1543
|
+
// gone, which is the entire point of the tombstone.
|
|
1544
|
+
let batchRejectedSkips = 0;
|
|
1545
|
+
const skippedWriteIds = new Set();
|
|
1318
1546
|
for (const entry of stampedWrites) {
|
|
1319
|
-
|
|
1547
|
+
const entryTenantId = entry.tenantId ?? 'default';
|
|
1548
|
+
// Codex delta-review P2 fix: reuse checkRejectionGuard rather than a
|
|
1549
|
+
// bare tombstone probe — the guard's content-INTRODUCTION
|
|
1550
|
+
// classification must apply here too. A tombstone can legitimately
|
|
1551
|
+
// coexist with a live same-content row (resolveConflict deliberately
|
|
1552
|
+
// excludes keepId from its sweep; unreject-then-re-reject windows), and
|
|
1553
|
+
// an unconditional skip would starve that row of decay/replay metadata
|
|
1554
|
+
// updates forever. The guard throws only when the write is new-row or
|
|
1555
|
+
// changes content TO the rejected value; unchanged same-id re-persists
|
|
1556
|
+
// pass through, exactly as on the writeEntry path.
|
|
1557
|
+
try {
|
|
1558
|
+
checkRejectionGuard(db, entryTenantId, entry.id, entry.content);
|
|
1559
|
+
}
|
|
1560
|
+
catch (err) {
|
|
1561
|
+
if (err instanceof RejectedValueError) {
|
|
1562
|
+
batchRejectedSkips++;
|
|
1563
|
+
skippedWriteIds.add(entry.id);
|
|
1564
|
+
audit(db, 'reject_refusal', entry.id, { digest: err.digest, reason: err.reason }, 'sleep-batch', entryTenantId);
|
|
1565
|
+
continue;
|
|
1566
|
+
}
|
|
1567
|
+
throw err;
|
|
1568
|
+
}
|
|
1569
|
+
// AT1 (plan §3, corrected): bypass the rejection guard here.
|
|
1570
|
+
// Consolidation merges are DETERMINISTIC CONCATENATION (mergeContents,
|
|
1571
|
+
// consolidate.ts:736-751) of already-guarded leaf facts, not an LLM
|
|
1572
|
+
// paraphrase — refusing mid-batch would abort the whole consolidation
|
|
1573
|
+
// transaction. The bypass is safe because consolidate.ts's merge pass
|
|
1574
|
+
// now checks the merged content's rejection digest against the
|
|
1575
|
+
// tenant's tombstones BEFORE ever pushing a merge into pendingWrites,
|
|
1576
|
+
// skipping that merge entirely on a hit, AND because the point-probe
|
|
1577
|
+
// immediately above closes the race window between that producer
|
|
1578
|
+
// check and this COMMIT. The guard itself still belongs on leaf
|
|
1579
|
+
// inserts, which write through writeEntry / writeEntryDbOnly and stay
|
|
1580
|
+
// guarded (bypassRejectionGuard defaults false).
|
|
1581
|
+
upsertEntryRow(db, entry, true);
|
|
1320
1582
|
// Hook for writes: child upserted under a level-2 summary marks parent dirty.
|
|
1321
1583
|
if (entry.dag_parent_id) {
|
|
1322
1584
|
dirtyParents.add(entry.dag_parent_id);
|
|
@@ -1333,8 +1595,15 @@ export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds) {
|
|
|
1333
1595
|
markSummaryDirtyInTx(db, parentId, tenantById.get(parentId) ?? 'default', 'batch');
|
|
1334
1596
|
}
|
|
1335
1597
|
db.exec('COMMIT');
|
|
1336
|
-
|
|
1598
|
+
if (batchRejectedSkips > 0) {
|
|
1599
|
+
console.error(`batchWriteAndDelete: skipped ${batchRejectedSkips} write(s) whose content matches a rejected value (tombstone hit during the batch transaction)`);
|
|
1600
|
+
}
|
|
1601
|
+
// Sync mirrors once after all DB writes. Entries skipped above were
|
|
1602
|
+
// never inserted — writing their markdown mirror would resurrect the
|
|
1603
|
+
// exact content the skip just kept out of the DB.
|
|
1337
1604
|
for (const entry of stampedWrites) {
|
|
1605
|
+
if (skippedWriteIds.has(entry.id))
|
|
1606
|
+
continue;
|
|
1338
1607
|
writeMarkdownMirror(hippoRoot, entry);
|
|
1339
1608
|
}
|
|
1340
1609
|
for (const id of toDeleteIds) {
|
|
@@ -1445,9 +1714,28 @@ export function rebuildIndex(hippoRoot) {
|
|
|
1445
1714
|
if (legacyEntries.length > 0) {
|
|
1446
1715
|
db.exec('BEGIN');
|
|
1447
1716
|
try {
|
|
1717
|
+
// AT1 (plan §3, round-3 redesign): same guard-with-per-row-skip as
|
|
1718
|
+
// bootstrapLegacyStore — rebuildIndex is the other channel through
|
|
1719
|
+
// which a stale markdown mirror could resurrect a rejected value.
|
|
1720
|
+
// Refusal audit written INLINE (nothing rolls back on a skip).
|
|
1721
|
+
let rejectedCount = 0;
|
|
1448
1722
|
for (const entry of legacyEntries) {
|
|
1449
1723
|
// v39: same store-derived origin stamp as bootstrapLegacyStore.
|
|
1450
|
-
|
|
1724
|
+
const stamped = stampOriginProjectForImport(hippoRoot, entry);
|
|
1725
|
+
try {
|
|
1726
|
+
upsertEntryRow(db, stamped);
|
|
1727
|
+
}
|
|
1728
|
+
catch (err) {
|
|
1729
|
+
if (err instanceof RejectedValueError) {
|
|
1730
|
+
rejectedCount++;
|
|
1731
|
+
audit(db, 'reject_refusal', err.entryId, { digest: err.digest, reason: err.reason }, 'cli', err.tenantId);
|
|
1732
|
+
continue;
|
|
1733
|
+
}
|
|
1734
|
+
throw err;
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
if (rejectedCount > 0) {
|
|
1738
|
+
console.error(`rebuildIndex: skipped ${rejectedCount} rejected value(s) found in legacy mirrors`);
|
|
1451
1739
|
}
|
|
1452
1740
|
db.exec('COMMIT');
|
|
1453
1741
|
}
|
|
@@ -1916,11 +2204,19 @@ export function replaceDetectedConflicts(hippoRoot, detected, detectedAt = new D
|
|
|
1916
2204
|
/**
|
|
1917
2205
|
* Resolve a conflict by keeping one memory and weakening the other.
|
|
1918
2206
|
* Sets conflict status to 'resolved' and halves the loser's half-life.
|
|
1919
|
-
* If --forget is used, the loser is
|
|
2207
|
+
* If --forget is used, the loser is removed entirely (kind-aware: raw rows
|
|
2208
|
+
* are archived via archiveRawMemory, others deleted via deleteEntryCore —
|
|
2209
|
+
* AT1 fix for the pre-existing crash where a raw loser aborted the whole
|
|
2210
|
+
* resolve transaction against the append-only trigger). `opts.rejectLoserValue`
|
|
2211
|
+
* additionally tombstones the loser's normalized digest so it cannot be
|
|
2212
|
+
* re-asserted later.
|
|
2213
|
+
*
|
|
2214
|
+
* Every resolution path (weaken / forget / reject) emits a `conflict_resolve`
|
|
2215
|
+
* audit row (AT1 — previously resolveConflict wrote zero audit rows on any path).
|
|
1920
2216
|
*
|
|
1921
2217
|
* Returns the resolved conflict, or null if not found.
|
|
1922
2218
|
*/
|
|
1923
|
-
export function resolveConflict(hippoRoot, conflictId, keepId, forgetLoser = false, tenantId) {
|
|
2219
|
+
export function resolveConflict(hippoRoot, conflictId, keepId, forgetLoser = false, tenantId, opts) {
|
|
1924
2220
|
initStore(hippoRoot);
|
|
1925
2221
|
const db = openHippoDb(hippoRoot);
|
|
1926
2222
|
// When tenantId is set, the conflict lookup requires BOTH members in-tenant
|
|
@@ -1959,9 +2255,81 @@ export function resolveConflict(hippoRoot, conflictId, keepId, forgetLoser = fal
|
|
|
1959
2255
|
// Mark conflict as resolved
|
|
1960
2256
|
db.prepare(`UPDATE memory_conflicts SET status = 'resolved', updated_at = datetime('now') WHERE id = ?`)
|
|
1961
2257
|
.run(conflictId);
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
2258
|
+
// AT1 (plan §5): removal (forgetLoser OR rejectLoserValue — a tombstoned
|
|
2259
|
+
// value cannot be left live) is now kind-aware. The old bare
|
|
2260
|
+
// `DELETE FROM memories WHERE id = ?` aborted the whole transaction when
|
|
2261
|
+
// the loser was kind='raw' (append-only trigger fires); route through
|
|
2262
|
+
// the same helpers the reject verb uses (both db-scoped, both compose
|
|
2263
|
+
// inside this BEGIN/COMMIT). loserRemoved / loserWasRaw drive both the
|
|
2264
|
+
// conflicts_with_json skip below and the post-commit mirror purge.
|
|
2265
|
+
let loserRemoved = false;
|
|
2266
|
+
let loserWasRaw = false;
|
|
2267
|
+
let rejectedDigest;
|
|
2268
|
+
// AT1 P1 fix (codex): same-tenant duplicates of the loser's content that
|
|
2269
|
+
// rejectLoserValue also removes (see below) — separate from loserId so
|
|
2270
|
+
// the audit + post-commit mirror purge can cover ALL of them, not just
|
|
2271
|
+
// loserId.
|
|
2272
|
+
const extraRemovedIds = [];
|
|
2273
|
+
const extraRemovedRawIds = [];
|
|
2274
|
+
const removeLoser = forgetLoser || opts?.rejectLoserValue === true;
|
|
2275
|
+
if (removeLoser) {
|
|
2276
|
+
const loserRow = db
|
|
2277
|
+
.prepare(`SELECT kind, content, tenant_id FROM memories WHERE id = ?${memScope}`)
|
|
2278
|
+
.get(loserId, ...memArgs);
|
|
2279
|
+
if (loserRow) {
|
|
2280
|
+
const actor = opts?.rejectedBy ?? 'cli';
|
|
2281
|
+
const reason = opts?.reason ?? `resolveConflict ${conflictId}: kept ${keepId}`;
|
|
2282
|
+
if (opts?.rejectLoserValue) {
|
|
2283
|
+
rejectedDigest = rejectionDigest(loserRow.content);
|
|
2284
|
+
insertRejectedValue(db, {
|
|
2285
|
+
tenantId: loserRow.tenant_id ?? 'default',
|
|
2286
|
+
digest: rejectedDigest,
|
|
2287
|
+
reason,
|
|
2288
|
+
rejectedBy: actor,
|
|
2289
|
+
rejectedAt: new Date().toISOString(),
|
|
2290
|
+
sourceMemoryId: loserId,
|
|
2291
|
+
normalizedChars: normalizeValueForRejection(loserRow.content).length,
|
|
2292
|
+
});
|
|
2293
|
+
// AT1 P1 fix (codex): reject-flow.ts's `rejectValue` removes ALL
|
|
2294
|
+
// live same-tenant rows whose normalized digest matches, not just
|
|
2295
|
+
// the one id passed — but this branch only ever removed loserId,
|
|
2296
|
+
// leaving same-TENANT duplicates live while their shared content
|
|
2297
|
+
// was tombstoned. Same O(N) scan pattern as reject-flow.ts (human-
|
|
2298
|
+
// triggered command, tenant's row count is human-scale). CRITICAL
|
|
2299
|
+
// BOUNDARY: tenant-scoped ONLY — tombstones are tenant-scoped by
|
|
2300
|
+
// design, so a same-content row in ANOTHER tenant is legitimately
|
|
2301
|
+
// live and must NOT be touched here. `keepId` is excluded even if
|
|
2302
|
+
// its content coincidentally matches: the human explicitly chose
|
|
2303
|
+
// to keep it in this same resolution, and this branch must not
|
|
2304
|
+
// undo that choice in the same transaction.
|
|
2305
|
+
const loserTenantId = loserRow.tenant_id ?? 'default';
|
|
2306
|
+
const dupRows = db
|
|
2307
|
+
.prepare(`SELECT id, kind, content FROM memories WHERE tenant_id = ? AND id != ? AND id != ?`)
|
|
2308
|
+
.all(loserTenantId, loserId, keepId);
|
|
2309
|
+
for (const dup of dupRows) {
|
|
2310
|
+
if (rejectionDigest(dup.content) !== rejectedDigest)
|
|
2311
|
+
continue;
|
|
2312
|
+
if (dup.kind === 'raw') {
|
|
2313
|
+
archiveRawMemory(db, dup.id, { reason, who: actor });
|
|
2314
|
+
extraRemovedRawIds.push(dup.id);
|
|
2315
|
+
}
|
|
2316
|
+
else {
|
|
2317
|
+
deleteEntryCore(db, dup.id, { actor, suppressForgetAudit: true });
|
|
2318
|
+
}
|
|
2319
|
+
extraRemovedIds.push(dup.id);
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
if (loserRow.kind === 'raw') {
|
|
2323
|
+
archiveRawMemory(db, loserId, { reason, who: actor });
|
|
2324
|
+
loserWasRaw = true;
|
|
2325
|
+
}
|
|
2326
|
+
else {
|
|
2327
|
+
deleteEntryCore(db, loserId, { actor, suppressForgetAudit: true });
|
|
2328
|
+
}
|
|
2329
|
+
loserRemoved = true;
|
|
2330
|
+
}
|
|
2331
|
+
// loserRow undefined = tenant-scope mismatch (or already gone); matches
|
|
2332
|
+
// the old tenant-scoped DELETE's silent 0-rows-affected behavior.
|
|
1965
2333
|
}
|
|
1966
2334
|
else {
|
|
1967
2335
|
// Halve the loser's half-life (weakens it over time)
|
|
@@ -1976,7 +2344,7 @@ export function resolveConflict(hippoRoot, conflictId, keepId, forgetLoser = fal
|
|
|
1976
2344
|
db.prepare(`UPDATE memories SET conflicts_with_json = ?, updated_at = datetime('now') WHERE id = ?${memScope}`)
|
|
1977
2345
|
.run(JSON.stringify(cleaned), keepId, ...memArgs);
|
|
1978
2346
|
}
|
|
1979
|
-
if (!
|
|
2347
|
+
if (!loserRemoved) {
|
|
1980
2348
|
const loserRow = db.prepare(`SELECT conflicts_with_json FROM memories WHERE id = ?${memScope}`).get(loserId, ...memArgs);
|
|
1981
2349
|
if (loserRow) {
|
|
1982
2350
|
const refs = JSON.parse(loserRow.conflicts_with_json || '[]');
|
|
@@ -1985,8 +2353,55 @@ export function resolveConflict(hippoRoot, conflictId, keepId, forgetLoser = fal
|
|
|
1985
2353
|
.run(JSON.stringify(cleaned), loserId, ...memArgs);
|
|
1986
2354
|
}
|
|
1987
2355
|
}
|
|
2356
|
+
// AT1: the missing audit (plan §5 — resolveConflict wrote ZERO audit_log
|
|
2357
|
+
// rows on any path before this). Every path — weaken, forget, reject —
|
|
2358
|
+
// lands exactly one conflict_resolve row.
|
|
2359
|
+
audit(db, 'conflict_resolve', keepId, {
|
|
2360
|
+
conflictId,
|
|
2361
|
+
keepId,
|
|
2362
|
+
loserId,
|
|
2363
|
+
disposition: loserRemoved ? (loserWasRaw ? 'archived_raw' : 'deleted') : 'weakened',
|
|
2364
|
+
rejected: Boolean(opts?.rejectLoserValue),
|
|
2365
|
+
rejectedDigest,
|
|
2366
|
+
// AT1 P1 fix: every row this call removed, not just loserId — the
|
|
2367
|
+
// same-tenant duplicate sweep above (extraRemovedIds) needs an
|
|
2368
|
+
// audit trail too.
|
|
2369
|
+
removedIds: loserRemoved ? [loserId, ...extraRemovedIds] : [],
|
|
2370
|
+
}, opts?.rejectedBy ?? 'cli', tenantId);
|
|
1988
2371
|
db.exec('COMMIT');
|
|
1989
2372
|
syncMirrorFiles(hippoRoot, db);
|
|
2373
|
+
// AT1 P1b fix: mirror purge + reaper stamp for EVERY removed loser, not
|
|
2374
|
+
// just the rejectLoserValue path. Pre-AT1, the plain forgetLoser path on
|
|
2375
|
+
// a raw loser crashed outright (bare DELETE FROM memories hit the
|
|
2376
|
+
// append-only trigger) — there is no legacy "successful forget, no
|
|
2377
|
+
// purge" behavior to preserve for that case. Post-AT1's kind-aware
|
|
2378
|
+
// removal (archiveRawMemory / deleteEntryCore above) makes plain
|
|
2379
|
+
// --forget succeed on every kind, but until this fix the mirror was
|
|
2380
|
+
// only purged when rejectLoserValue was ALSO set: a plain raw --forget
|
|
2381
|
+
// left its markdown mirror orphaned (the reaper still catches it
|
|
2382
|
+
// eventually, since archiveRawMemory's own raw_archive insert leaves
|
|
2383
|
+
// mirror_cleaned_at NULL) and a plain non-raw --forget left its mirror
|
|
2384
|
+
// orphaned FOREVER (no reaper exists for non-raw rows). Same post-commit
|
|
2385
|
+
// purge+reaper pattern as the reject verb (src/reject-flow.ts) and
|
|
2386
|
+
// api.archiveRaw — reusing removeEntryMirrors + raw_archive bookkeeping.
|
|
2387
|
+
if (loserRemoved) {
|
|
2388
|
+
// AT1 P1 fix: loop over loserId AND every same-tenant duplicate the
|
|
2389
|
+
// rejectLoserValue sweep above removed (extraRemovedIds) — previously
|
|
2390
|
+
// only loserId's mirror was purged, leaving duplicate mirrors orphaned
|
|
2391
|
+
// despite their rows being gone.
|
|
2392
|
+
for (const removedId of [loserId, ...extraRemovedIds]) {
|
|
2393
|
+
const isRaw = removedId === loserId ? loserWasRaw : extraRemovedRawIds.includes(removedId);
|
|
2394
|
+
// AT1 fix: purgeMirrorBestEffort retries once, then — for non-raw ids,
|
|
2395
|
+
// which cleanupArchivedMirrors' reaper never scans — reports the
|
|
2396
|
+
// EXPLICIT leftover path(s) instead of the false "will retry via
|
|
2397
|
+
// reaper" claim. See its own doc comment (store.ts, near
|
|
2398
|
+
// removeEntryMirrors) for the full rationale.
|
|
2399
|
+
const mirrorOk = purgeMirrorBestEffort(hippoRoot, removedId, isRaw, 'resolveConflict');
|
|
2400
|
+
if (mirrorOk && isRaw) {
|
|
2401
|
+
db.prepare(`UPDATE raw_archive SET mirror_cleaned_at = ? WHERE memory_id = ?`).run(new Date().toISOString(), removedId);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
1990
2405
|
return { conflict: { ...conflict, status: 'resolved' }, loserId };
|
|
1991
2406
|
}
|
|
1992
2407
|
catch (error) {
|
|
@@ -2288,8 +2703,11 @@ export function loadChildrenOfSummary(hippoRoot, summaryId, tenantId) {
|
|
|
2288
2703
|
* WHERE includes `AND summary_dirty = 1` so concurrent sleep's race-loser
|
|
2289
2704
|
* becomes a no-op (no rebuild_count bump, no audit row).
|
|
2290
2705
|
*
|
|
2291
|
-
* Returns
|
|
2292
|
-
*
|
|
2706
|
+
* Returns `{ changed, refused }`. `changed` is true when this call's UPDATE
|
|
2707
|
+
* (content or metadata-only) affected a row; false on race-loss / unknown id
|
|
2708
|
+
* / archived / wrong dag_level. `refused` is true only when a tombstone hit
|
|
2709
|
+
* suppressed the content write AND the metadata UPDATE still landed — see
|
|
2710
|
+
* the return-semantics comment below for the full contract.
|
|
2293
2711
|
*/
|
|
2294
2712
|
export function applyRebuildResult(hippoRoot, summary, patch) {
|
|
2295
2713
|
assertTenantId('applyRebuildResult', summary.tenantId);
|
|
@@ -2299,9 +2717,31 @@ export function applyRebuildResult(hippoRoot, summary, patch) {
|
|
|
2299
2717
|
db.exec('SAVEPOINT rebuild_summary');
|
|
2300
2718
|
try {
|
|
2301
2719
|
const nowIso = new Date().toISOString();
|
|
2720
|
+
// AT1 P1a fix (docs/plans/2026-08-15-at1-rejected-value-tombstone.md):
|
|
2721
|
+
// applyRebuildResult's bumpRebuildCount branch wrote patch.content via
|
|
2722
|
+
// a direct UPDATE, bypassing the rejection guard entirely (the guard
|
|
2723
|
+
// lives in upsertEntryRow's INSERT path, which this function never
|
|
2724
|
+
// calls). A rebuild that regenerates byte-identical content to an
|
|
2725
|
+
// already-rejected value (e.g. deterministic summarization of an
|
|
2726
|
+
// unchanged child set) would silently re-assert it every sleep cycle.
|
|
2727
|
+
// Check BEFORE choosing which UPDATE to run — only the
|
|
2728
|
+
// bumpRebuildCount branch ever writes content, so a miss or a
|
|
2729
|
+
// zero-child call is a no-op here (one indexed point query, guarded
|
|
2730
|
+
// path only).
|
|
2731
|
+
const tombstone = patch.bumpRebuildCount
|
|
2732
|
+
? findRejectedValue(db, summary.tenantId, rejectionDigest(patch.content))
|
|
2733
|
+
: null;
|
|
2734
|
+
// On a hit: do NOT write the new content. Fall through to the SAME
|
|
2735
|
+
// metadata-only behavior the zero-child branch already has —
|
|
2736
|
+
// descendant_count/earliest_at/latest_at update + summary_dirty
|
|
2737
|
+
// cleared, no content write, no rebuild_count bump. Clearing dirty
|
|
2738
|
+
// (rather than leaving it set) is deliberate: leaving it dirty would
|
|
2739
|
+
// make every following sleep cycle re-attempt and re-refuse the
|
|
2740
|
+
// identical rebuild forever (the DAG-loop this fix closes).
|
|
2741
|
+
const applyContentWrite = patch.bumpRebuildCount && !tombstone;
|
|
2302
2742
|
// ONE prepared UPDATE per branch. Test #8 inspects the SQL string.
|
|
2303
2743
|
// v0.30 / E5: widened dag_level=2 -> IN (2, 3) on both branches.
|
|
2304
|
-
const sql =
|
|
2744
|
+
const sql = applyContentWrite
|
|
2305
2745
|
? `UPDATE memories
|
|
2306
2746
|
SET content = ?,
|
|
2307
2747
|
descendant_count = ?,
|
|
@@ -2325,24 +2765,57 @@ export function applyRebuildResult(hippoRoot, summary, patch) {
|
|
|
2325
2765
|
AND dag_level IN (2, 3)
|
|
2326
2766
|
AND summary_dirty = 1
|
|
2327
2767
|
AND kind != 'archived'`;
|
|
2328
|
-
const result =
|
|
2768
|
+
const result = applyContentWrite
|
|
2329
2769
|
? db.prepare(sql).run(patch.content, patch.descendant_count, patch.earliest_at, patch.latest_at, nowIso, summary.id, summary.tenantId)
|
|
2330
2770
|
: db.prepare(sql).run(patch.descendant_count, patch.earliest_at, patch.latest_at, summary.id, summary.tenantId);
|
|
2771
|
+
// Return-value semantics (v0.30/T4 split): `changed` reflects whether
|
|
2772
|
+
// THIS call's UPDATE (content or metadata-only) affected a row — NOT
|
|
2773
|
+
// whether patch.content specifically landed. On a refusal, metadata
|
|
2774
|
+
// still applies, so changed=true even though content did not change.
|
|
2775
|
+
// This preserves the pre-T4 no-infinite-retry choice: the caller
|
|
2776
|
+
// (dag.ts rebuildDirtySummaries) treats changed=false as "race lost,
|
|
2777
|
+
// silently retry next cycle" — returning false on a refusal would
|
|
2778
|
+
// retry the same doomed LLM rebuild forever, so changed=true settles
|
|
2779
|
+
// this cycle (dirty cleared) regardless of refusal.
|
|
2780
|
+
// `refused` is the T4 addition: true only when a tombstone hit AND
|
|
2781
|
+
// the metadata UPDATE landed (changed=true) — a refusal that loses
|
|
2782
|
+
// the race to a concurrent writer reports refused=false too, since
|
|
2783
|
+
// nothing from this call took effect. Before T4, a refusal also
|
|
2784
|
+
// counted toward the caller's `rebuilt` stat because `changed` alone
|
|
2785
|
+
// could not distinguish it; the caller now increments `refused`
|
|
2786
|
+
// instead of `rebuilt` when this is true, so the stat reflects what
|
|
2787
|
+
// happened without changing dirty-clearing or retry behavior.
|
|
2331
2788
|
const changed = (result.changes ?? 0) > 0;
|
|
2789
|
+
const refused = Boolean(tombstone) && changed;
|
|
2790
|
+
if (tombstone && changed) {
|
|
2791
|
+
// refused === true here (same condition, narrowed for the tombstone.*
|
|
2792
|
+
// access below). Best-effort refusal audit, written INLINE inside
|
|
2793
|
+
// this still-open SAVEPOINT — nothing here rolls back on a refusal
|
|
2794
|
+
// (the metadata UPDATE above already committed to this savepoint), so the
|
|
2795
|
+
// post-rollback auditRejectionRefusal helper (writeEntry/supersede's
|
|
2796
|
+
// tool) is the wrong one here; a direct audit() call is correct and
|
|
2797
|
+
// commits with the rest of this savepoint.
|
|
2798
|
+
audit(db, 'reject_refusal', summary.id, { digest: tombstone.digest, reason: tombstone.reason }, patch.actor, summary.tenantId);
|
|
2799
|
+
console.error(`applyRebuildResult: refused rebuild content for ${summary.id} — matches a rejected value ` +
|
|
2800
|
+
`(digest ${tombstone.digest.slice(0, 12)}...); metadata updated, content unchanged`);
|
|
2801
|
+
}
|
|
2332
2802
|
if (changed) {
|
|
2333
2803
|
// FTS sync — bare UPDATE on memories does NOT update memories_fts.
|
|
2334
2804
|
// R1 HIGH must-fix from plan-eng-r1. Construct the patched entry in
|
|
2335
2805
|
// memory and reuse the existing syncFtsRow helper (delete-then-insert).
|
|
2336
2806
|
// earliest_at/latest_at preserve null semantics (R2 must-fix).
|
|
2807
|
+
// AT1: content stays summary.content (unchanged) when the write was
|
|
2808
|
+
// refused — applyContentWrite is false, so patch.content was never
|
|
2809
|
+
// written to the row FTS must mirror.
|
|
2337
2810
|
const patchedEntry = {
|
|
2338
2811
|
...summary,
|
|
2339
|
-
content: patch.content,
|
|
2812
|
+
content: applyContentWrite ? patch.content : summary.content,
|
|
2340
2813
|
descendant_count: patch.descendant_count,
|
|
2341
2814
|
earliest_at: patch.earliest_at,
|
|
2342
2815
|
latest_at: patch.latest_at,
|
|
2343
2816
|
summary_dirty: 0,
|
|
2344
|
-
last_rebuilt_at:
|
|
2345
|
-
rebuild_count:
|
|
2817
|
+
last_rebuilt_at: applyContentWrite ? nowIso : summary.last_rebuilt_at,
|
|
2818
|
+
rebuild_count: applyContentWrite
|
|
2346
2819
|
? (summary.rebuild_count ?? 0) + 1
|
|
2347
2820
|
: summary.rebuild_count,
|
|
2348
2821
|
};
|
|
@@ -2357,7 +2830,7 @@ export function applyRebuildResult(hippoRoot, summary, patch) {
|
|
|
2357
2830
|
}, patch.actor, summary.tenantId);
|
|
2358
2831
|
}
|
|
2359
2832
|
db.exec('RELEASE SAVEPOINT rebuild_summary');
|
|
2360
|
-
return changed;
|
|
2833
|
+
return { changed, refused };
|
|
2361
2834
|
}
|
|
2362
2835
|
catch (e) {
|
|
2363
2836
|
try {
|