moflo 4.12.11 → 4.13.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/.claude/guidance/shipped/moflo-cli-reference.md +45 -1
- package/.claude/guidance/shipped/moflo-cross-install-memory-sharing.md +7 -2
- package/.claude/guidance/shipped/moflo-skills-reference.md +2 -0
- package/.claude/skills/fl/phases.md +51 -17
- package/.claude/skills/optimize-learnings/SKILL.md +220 -0
- package/README.md +95 -1
- package/bin/lib/get-backend.mjs +150 -12
- package/bin/lib/skill-categories.mjs +1 -0
- package/bin/session-start-launcher.mjs +13 -5
- package/dist/src/cli/commands/daemon.js +5 -2
- package/dist/src/cli/commands/epic.js +5 -1
- package/dist/src/cli/commands/hive-mind.js +6 -4
- package/dist/src/cli/commands/hooks.js +8 -8
- package/dist/src/cli/commands/index.js +5 -0
- package/dist/src/cli/commands/memory-audit-learnings.js +587 -0
- package/dist/src/cli/commands/memory.js +71 -10
- package/dist/src/cli/commands/spell-schedule.js +5 -3
- package/dist/src/cli/commands/worktree.js +408 -0
- package/dist/src/cli/config/moflo-config.js +57 -0
- package/dist/src/cli/index.js +4 -2
- package/dist/src/cli/init/executor.js +1 -0
- package/dist/src/cli/mcp-tools/memory-admin-tools.js +46 -8
- package/dist/src/cli/mcp-tools/moflodb-tools.js +30 -6
- package/dist/src/cli/memory/bridge-entries.js +157 -9
- package/dist/src/cli/memory/controllers/batch-operations.js +7 -2
- package/dist/src/cli/memory/daemon-backend.js +152 -11
- package/dist/src/cli/memory/entries-read.js +47 -2
- package/dist/src/cli/memory/entries-write.js +73 -10
- package/dist/src/cli/memory/hnsw-singleton.js +112 -9
- package/dist/src/cli/memory/learnings-audit.js +420 -0
- package/dist/src/cli/memory/learnings-dead-paths.js +202 -0
- package/dist/src/cli/memory/learnings-tree.js +187 -0
- package/dist/src/cli/memory/memory-bridge.js +37 -27
- package/dist/src/cli/memory/tool-call-markup.js +218 -0
- package/dist/src/cli/parser.js +7 -3
- package/dist/src/cli/services/cherry-pick-learnings.js +9 -3
- package/dist/src/cli/services/durable-reconcile.js +161 -0
- package/dist/src/cli/services/durable-store-io.js +291 -0
- package/dist/src/cli/services/durable-sync.js +159 -24
- package/dist/src/cli/services/team-artifact-sync.js +462 -163
- package/dist/src/cli/services/worktree-provision.js +400 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable-learning reconciliation plan (#1463).
|
|
3
|
+
*
|
|
4
|
+
* Before this module, every path that synced durable learnings between two
|
|
5
|
+
* stores was additive on keys: team export skipped any key already in the
|
|
6
|
+
* artifact, team import ran `INSERT OR IGNORE`, and the worktree durable sync
|
|
7
|
+
* ran both directions through the same ignore-on-conflict copy. A correction
|
|
8
|
+
* and a deletion were dropped in every direction, so a purge could not be made
|
|
9
|
+
* to stick — the entry came back at the next session-start import.
|
|
10
|
+
*
|
|
11
|
+
* That was three independent copies of the same defect. This module is the ONE
|
|
12
|
+
* implementation of the merge rule; the four call sites differ only in how they
|
|
13
|
+
* read records out of their store and how they apply the resulting actions.
|
|
14
|
+
* Adding a fifth sync path means reading records into {@link ReconcileRecord}
|
|
15
|
+
* and applying {@link ReconcileAction} — never re-deriving the rule.
|
|
16
|
+
*
|
|
17
|
+
* The module is deliberately pure: no fs, no sqlite, no clock. Everything it
|
|
18
|
+
* decides is a function of the two maps handed to it, which is what makes the
|
|
19
|
+
* conflict matrix directly testable in both orderings.
|
|
20
|
+
*
|
|
21
|
+
* ## The rule
|
|
22
|
+
*
|
|
23
|
+
* A record is either **live** (has `content`) or a **tombstone** (has
|
|
24
|
+
* `deletedAt`). For each key present in the SOURCE:
|
|
25
|
+
*
|
|
26
|
+
* | source | target | outcome |
|
|
27
|
+
* |---|---|---|
|
|
28
|
+
* | live | absent | `insert` |
|
|
29
|
+
* | live | live, same content | unchanged |
|
|
30
|
+
* | live | live, source newer | `update` |
|
|
31
|
+
* | live | live, target newer | kept (target wins) |
|
|
32
|
+
* | live | tombstone, source newer | `resurrect` |
|
|
33
|
+
* | live | tombstone, tombstone newer | kept (the purge stands) |
|
|
34
|
+
* | tombstone | absent | nothing — never shared, nothing to delete |
|
|
35
|
+
* | tombstone | live, tombstone newer | `delete` |
|
|
36
|
+
* | tombstone | live, target newer | kept (re-created after the purge) |
|
|
37
|
+
* | tombstone | tombstone | unchanged |
|
|
38
|
+
*
|
|
39
|
+
* And the rule that matters most, covering every key present in the TARGET but
|
|
40
|
+
* absent from the source: **leave it alone.** The naive alternative — delete
|
|
41
|
+
* anything the source lacks — cannot tell a remote deletion from local work
|
|
42
|
+
* that was never exported, and would destroy the latter. Only an explicit
|
|
43
|
+
* tombstone ever deletes.
|
|
44
|
+
*
|
|
45
|
+
* Comparisons are strict: equal timestamps produce no action. A tie means two
|
|
46
|
+
* stores disagree with no evidence about which is later, and the safe reading
|
|
47
|
+
* of "no evidence" is "change nothing".
|
|
48
|
+
*
|
|
49
|
+
* @module cli/services/durable-reconcile
|
|
50
|
+
*/
|
|
51
|
+
/** Separator for the composite (namespace, key) identity. NUL can't occur in either. */
|
|
52
|
+
const ID_SEP = String.fromCharCode(0);
|
|
53
|
+
/**
|
|
54
|
+
* Composite identity for a durable row. Namespaces share keys — the
|
|
55
|
+
* `knowledge` → `learnings` migration guarantees overlap — so a key alone is
|
|
56
|
+
* not an identity and never was.
|
|
57
|
+
*/
|
|
58
|
+
export function reconcileId(namespace, key) {
|
|
59
|
+
return `${namespace}${ID_SEP}${key}`;
|
|
60
|
+
}
|
|
61
|
+
/** Split a {@link reconcileId} back into its parts. */
|
|
62
|
+
export function splitReconcileId(id) {
|
|
63
|
+
const at = id.indexOf(ID_SEP);
|
|
64
|
+
return at < 0
|
|
65
|
+
? { namespace: '', key: id }
|
|
66
|
+
: { namespace: id.slice(0, at), key: id.slice(at + 1) };
|
|
67
|
+
}
|
|
68
|
+
/** True when a record represents a deletion rather than a live entry. */
|
|
69
|
+
export function isTombstone(record) {
|
|
70
|
+
return typeof record.deletedAt === 'number';
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The instant a record last changed, whichever kind it is. Used for every
|
|
74
|
+
* cross-kind comparison (live vs tombstone) so the matrix stays one rule —
|
|
75
|
+
* exported because callers settling duplicates within one store need the same
|
|
76
|
+
* basis, and a second copy of it is a second policy waiting to diverge.
|
|
77
|
+
*/
|
|
78
|
+
export function recordStamp(record) {
|
|
79
|
+
return isTombstone(record) ? record.deletedAt : record.updatedAt;
|
|
80
|
+
}
|
|
81
|
+
/** True when the plan would change nothing. Callers use it to skip a write. */
|
|
82
|
+
export function isNoOpPlan(plan) {
|
|
83
|
+
return plan.actions.length === 0;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Compute what the target must do to match the source. Pure — the caller
|
|
87
|
+
* applies the actions and owns every side effect.
|
|
88
|
+
*
|
|
89
|
+
* Direction is entirely the caller's: pass (DB, artifact) to export and
|
|
90
|
+
* (artifact, DB) to import. The rule is symmetric, which is the point.
|
|
91
|
+
*/
|
|
92
|
+
export function planReconcile(source, target) {
|
|
93
|
+
const actions = [];
|
|
94
|
+
const summary = {
|
|
95
|
+
inserted: 0,
|
|
96
|
+
updated: 0,
|
|
97
|
+
deleted: 0,
|
|
98
|
+
resurrected: 0,
|
|
99
|
+
unchanged: 0,
|
|
100
|
+
keptTargetNewer: 0,
|
|
101
|
+
targetOnly: 0,
|
|
102
|
+
};
|
|
103
|
+
for (const [id, src] of source) {
|
|
104
|
+
const dst = target.get(id);
|
|
105
|
+
if (!dst) {
|
|
106
|
+
// A tombstone for a key the target never had is not a deletion to
|
|
107
|
+
// propagate — the entry was never shared there. Dropping it here is also
|
|
108
|
+
// what keeps a tombstone from resurrecting as a row on a fresh install.
|
|
109
|
+
if (isTombstone(src)) {
|
|
110
|
+
summary.unchanged++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
actions.push({ id, op: 'insert', record: src });
|
|
114
|
+
summary.inserted++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const srcDead = isTombstone(src);
|
|
118
|
+
const dstDead = isTombstone(dst);
|
|
119
|
+
if (srcDead && dstDead) {
|
|
120
|
+
// Both sides already agree the entry is gone. Refreshing the target's
|
|
121
|
+
// timestamp would churn the artifact diff for no semantic gain.
|
|
122
|
+
summary.unchanged++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!srcDead && !dstDead && src.content === dst.content) {
|
|
126
|
+
summary.unchanged++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
// Strict: a tie changes nothing. See the module header.
|
|
130
|
+
if (recordStamp(src) <= recordStamp(dst)) {
|
|
131
|
+
summary.keptTargetNewer++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const op = srcDead ? 'delete' : dstDead ? 'resurrect' : 'update';
|
|
135
|
+
actions.push({ id, op, record: src });
|
|
136
|
+
if (op === 'delete')
|
|
137
|
+
summary.deleted++;
|
|
138
|
+
else if (op === 'resurrect')
|
|
139
|
+
summary.resurrected++;
|
|
140
|
+
else
|
|
141
|
+
summary.updated++;
|
|
142
|
+
}
|
|
143
|
+
for (const id of target.keys()) {
|
|
144
|
+
if (!source.has(id))
|
|
145
|
+
summary.targetOnly++;
|
|
146
|
+
}
|
|
147
|
+
return { actions, summary };
|
|
148
|
+
}
|
|
149
|
+
/** Default age past which a tombstone is dropped from an artifact on export. */
|
|
150
|
+
export const TOMBSTONE_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
151
|
+
/**
|
|
152
|
+
* True when a tombstone is old enough to drop. Every store that was going to
|
|
153
|
+
* see the deletion has seen it long before 90 days, and keeping them forever
|
|
154
|
+
* would grow the artifact without bound.
|
|
155
|
+
*
|
|
156
|
+
* Live records are never prunable — the caller passes the whole map through.
|
|
157
|
+
*/
|
|
158
|
+
export function isPrunableTombstone(record, now, ttlMs = TOMBSTONE_TTL_MS) {
|
|
159
|
+
return isTombstone(record) && now - record.deletedAt > ttlMs;
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=durable-reconcile.js.map
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading durable rows out of a memory DB and applying a reconciliation plan
|
|
3
|
+
* back into one (#1463).
|
|
4
|
+
*
|
|
5
|
+
* The merge rule itself lives in {@link module:cli/services/durable-reconcile}
|
|
6
|
+
* and knows nothing about SQLite. This module is the other half: the SQL that
|
|
7
|
+
* turns `memory_entries` into {@link ReconcileRecord}s and the SQL that applies
|
|
8
|
+
* the resulting {@link ReconcileAction}s. Both the team JSONL artifact
|
|
9
|
+
* (`team-artifact-sync.ts`) and the worktree/cross-install durable sync
|
|
10
|
+
* (`durable-sync.ts`) use it, so the DB half of the sync exists once.
|
|
11
|
+
*
|
|
12
|
+
* ## Deletions are archives, not DELETEs
|
|
13
|
+
*
|
|
14
|
+
* A hard-deleted row is indistinguishable from a row that never existed, so it
|
|
15
|
+
* can never propagate — and "delete anything the other side lacks" is the rule
|
|
16
|
+
* we must not adopt (see the reconcile module header). Durable deletions
|
|
17
|
+
* therefore set `status = 'archived'` and stamp `updated_at` with the deletion
|
|
18
|
+
* time. Nothing else has to change for that to be invisible: every read path in
|
|
19
|
+
* moflo already filters `status = 'active'` — search, list, stats, retrieve,
|
|
20
|
+
* cleanup, and the HNSW index build. The archived row survives only as the
|
|
21
|
+
* evidence that lets the deletion cross to another store, and as the timestamp
|
|
22
|
+
* that lets a legitimate re-creation win later.
|
|
23
|
+
*
|
|
24
|
+
* ## Writer classification
|
|
25
|
+
*
|
|
26
|
+
* `daemon-offline`, same shape as `cherry-pick-learnings.ts` and the existing
|
|
27
|
+
* team-artifact import: these run pre-boot at session-start or from a CLI
|
|
28
|
+
* command, not concurrently with a live daemon writer. Registered in
|
|
29
|
+
* `tests/system/fixtures/writer-audit-whitelist.json` (#1054).
|
|
30
|
+
*
|
|
31
|
+
* @module cli/services/durable-store-io
|
|
32
|
+
*/
|
|
33
|
+
import { MEMORY_SCHEMA_V3 } from '../memory/schema.js';
|
|
34
|
+
import { DURABLE_NAMESPACES, DURABLE_INSERT_OR_IGNORE_SQL, DURABLE_ROW_COLUMNS, hasMemoryEntriesTable, isDurableNamespace, } from './cherry-pick-learnings.js';
|
|
35
|
+
// Re-exported so the two delete paths (`entries-write`, `bridge-entries`) can
|
|
36
|
+
// reach the durable check and the archive statement through ONE import,
|
|
37
|
+
// rather than each assembling its own copy of the retire-a-durable-row rule.
|
|
38
|
+
export { isDurableNamespace };
|
|
39
|
+
import { reconcileId, } from './durable-reconcile.js';
|
|
40
|
+
/** The `status` value that marks a durable row as deleted-but-propagatable. */
|
|
41
|
+
export const ARCHIVED_STATUS = 'archived';
|
|
42
|
+
// Same column list as the shared INSERT, so a future column can never be added
|
|
43
|
+
// to one half of the round trip only. The status filter is what differs from
|
|
44
|
+
// the legacy cherry-pick read: archived rows are the deletions we must carry.
|
|
45
|
+
const selectDurableSql = (placeholders, columns, byKey) => `SELECT ${columns} FROM memory_entries ` +
|
|
46
|
+
`WHERE namespace IN (${placeholders}) AND status IN ('active', '${ARCHIVED_STATUS}')` +
|
|
47
|
+
(byKey ? ` AND key = ?` : '');
|
|
48
|
+
/**
|
|
49
|
+
* The columns the merge rule alone needs. A comparison never looks at the
|
|
50
|
+
* embedding, and on a 1,600-row store those JSON vectors are tens of MB of
|
|
51
|
+
* string allocation — so the side of a sync that only supplies records (the
|
|
52
|
+
* TARGET, always) must not pay for them.
|
|
53
|
+
*/
|
|
54
|
+
const RECORD_ONLY_COLUMNS = `key, namespace, content, updated_at, status`;
|
|
55
|
+
/**
|
|
56
|
+
* Read the durable slice of a DB into comparable records plus full payloads.
|
|
57
|
+
* An archived row becomes a tombstone stamped with its `updated_at` — which is
|
|
58
|
+
* the deletion time, because that is what the archive write sets.
|
|
59
|
+
*
|
|
60
|
+
* Returns empty maps for a DB with no `memory_entries` table rather than
|
|
61
|
+
* throwing: a not-yet-initialised store is "nothing to sync", not an error.
|
|
62
|
+
*/
|
|
63
|
+
export function readDurableSnapshot(db, namespaces = DURABLE_NAMESPACES, opts = {}) {
|
|
64
|
+
const records = new Map();
|
|
65
|
+
const payloads = new Map();
|
|
66
|
+
if (!hasMemoryEntriesTable(db))
|
|
67
|
+
return { records, payloads };
|
|
68
|
+
const withPayloads = opts.withPayloads === true;
|
|
69
|
+
const placeholders = namespaces.map(() => '?').join(',');
|
|
70
|
+
const stmt = db.prepare(selectDurableSql(placeholders, withPayloads ? DURABLE_ROW_COLUMNS : RECORD_ONLY_COLUMNS, opts.key != null));
|
|
71
|
+
try {
|
|
72
|
+
stmt.bind(opts.key != null ? [...namespaces, opts.key] : namespaces.slice());
|
|
73
|
+
while (stmt.step()) {
|
|
74
|
+
const row = stmt.getAsObject();
|
|
75
|
+
const namespace = String(row.namespace);
|
|
76
|
+
const key = String(row.key);
|
|
77
|
+
const id = reconcileId(namespace, key);
|
|
78
|
+
const createdAt = row.created_at == null ? 0 : Number(row.created_at);
|
|
79
|
+
// A row predating the updated_at backfill falls back to created_at: for a
|
|
80
|
+
// DB row that IS the last-known edit time, unlike an artifact line where
|
|
81
|
+
// substituting created_at would let a stale line win (see the reconcile
|
|
82
|
+
// module's note on passing 0).
|
|
83
|
+
const updatedAt = row.updated_at == null ? createdAt : Number(row.updated_at);
|
|
84
|
+
const archived = String(row.status ?? 'active') === ARCHIVED_STATUS;
|
|
85
|
+
records.set(id, archived
|
|
86
|
+
? { namespace, key, updatedAt, deletedAt: updatedAt }
|
|
87
|
+
: { namespace, key, updatedAt, content: row.content == null ? '' : String(row.content) });
|
|
88
|
+
if (!withPayloads)
|
|
89
|
+
continue;
|
|
90
|
+
payloads.set(id, {
|
|
91
|
+
id: String(row.id),
|
|
92
|
+
namespace,
|
|
93
|
+
key,
|
|
94
|
+
content: row.content == null ? '' : String(row.content),
|
|
95
|
+
type: row.type == null ? 'semantic' : String(row.type),
|
|
96
|
+
tags: row.tags == null ? null : String(row.tags),
|
|
97
|
+
metadata: row.metadata == null ? null : String(row.metadata),
|
|
98
|
+
ownerId: row.owner_id == null ? null : String(row.owner_id),
|
|
99
|
+
createdAt,
|
|
100
|
+
updatedAt,
|
|
101
|
+
embedding: row.embedding == null ? null : String(row.embedding),
|
|
102
|
+
embeddingModel: row.embedding_model == null ? null : String(row.embedding_model),
|
|
103
|
+
embeddingDimensions: row.embedding_dimensions == null ? null : Number(row.embedding_dimensions),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
try {
|
|
109
|
+
stmt.free();
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
/* best-effort cleanup */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { records, payloads };
|
|
116
|
+
}
|
|
117
|
+
const UPDATE_ROW_SQL = `UPDATE memory_entries SET content = ?, type = ?, tags = ?, metadata = ?, ` +
|
|
118
|
+
`embedding = ?, embedding_model = ?, embedding_dimensions = ?, ` +
|
|
119
|
+
`updated_at = ?, status = 'active' WHERE namespace = ? AND key = ?`;
|
|
120
|
+
const ARCHIVE_DURABLE_ROW_SQL = `UPDATE memory_entries SET status = '${ARCHIVED_STATUS}', updated_at = ?, ` +
|
|
121
|
+
// The vector goes with the deletion: an archived row is excluded from index
|
|
122
|
+
// builds anyway, and dropping it means a later resurrect cannot reuse a
|
|
123
|
+
// vector that no longer matches whatever content wins.
|
|
124
|
+
`embedding = NULL, embedding_model = NULL, embedding_dimensions = NULL ` +
|
|
125
|
+
// `status = 'active'` guard: re-archiving an already-archived row would move
|
|
126
|
+
// its deletion timestamp forward and could beat a legitimate re-creation on
|
|
127
|
+
// the other side that had already won.
|
|
128
|
+
`WHERE namespace = ? AND key = ? AND status = 'active'`;
|
|
129
|
+
/**
|
|
130
|
+
* Apply a reconciliation plan to `db`. The whole batch runs in ONE transaction
|
|
131
|
+
* — a half-applied merge would leave the store in a state no later run could
|
|
132
|
+
* reason about, and a single commit is also one fsync instead of one per row on
|
|
133
|
+
* the session-start hot path.
|
|
134
|
+
*
|
|
135
|
+
* `payloads` supplies the full row for every insert/update/resurrect action;
|
|
136
|
+
* `delete` actions need only the timestamp the plan already carries.
|
|
137
|
+
*/
|
|
138
|
+
export function applyDurableActions(db, actions, payloads) {
|
|
139
|
+
const report = {
|
|
140
|
+
inserted: 0,
|
|
141
|
+
updated: 0,
|
|
142
|
+
archived: 0,
|
|
143
|
+
resurrected: 0,
|
|
144
|
+
skippedMissingPayload: 0,
|
|
145
|
+
skippedConflict: 0,
|
|
146
|
+
};
|
|
147
|
+
if (actions.length === 0)
|
|
148
|
+
return report;
|
|
149
|
+
db.run(MEMORY_SCHEMA_V3);
|
|
150
|
+
let insertStmt = null;
|
|
151
|
+
let updateStmt = null;
|
|
152
|
+
let archiveStmt = null;
|
|
153
|
+
try {
|
|
154
|
+
insertStmt = db.prepare(DURABLE_INSERT_OR_IGNORE_SQL);
|
|
155
|
+
updateStmt = db.prepare(UPDATE_ROW_SQL);
|
|
156
|
+
archiveStmt = db.prepare(ARCHIVE_DURABLE_ROW_SQL);
|
|
157
|
+
db.run('BEGIN');
|
|
158
|
+
try {
|
|
159
|
+
for (const action of actions) {
|
|
160
|
+
if (action.op === 'delete') {
|
|
161
|
+
const { namespace, key } = action.record;
|
|
162
|
+
archiveStmt.bind([action.record.deletedAt ?? Date.now(), namespace, key]);
|
|
163
|
+
archiveStmt.step();
|
|
164
|
+
if (db.getRowsModified() > 0)
|
|
165
|
+
report.archived++;
|
|
166
|
+
archiveStmt.reset();
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const payload = payloads.get(action.id);
|
|
170
|
+
if (!payload) {
|
|
171
|
+
report.skippedMissingPayload++;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (action.op === 'insert') {
|
|
175
|
+
insertStmt.bind([
|
|
176
|
+
payload.id,
|
|
177
|
+
payload.key,
|
|
178
|
+
payload.namespace,
|
|
179
|
+
payload.content,
|
|
180
|
+
payload.type,
|
|
181
|
+
payload.embedding,
|
|
182
|
+
payload.embeddingModel,
|
|
183
|
+
payload.embeddingDimensions,
|
|
184
|
+
payload.tags,
|
|
185
|
+
payload.metadata,
|
|
186
|
+
payload.ownerId,
|
|
187
|
+
payload.createdAt,
|
|
188
|
+
payload.updatedAt,
|
|
189
|
+
'active',
|
|
190
|
+
]);
|
|
191
|
+
insertStmt.step();
|
|
192
|
+
if (db.getRowsModified() > 0)
|
|
193
|
+
report.inserted++;
|
|
194
|
+
else
|
|
195
|
+
report.skippedConflict++;
|
|
196
|
+
insertStmt.reset();
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// update | resurrect — the same write. They differ only in what the
|
|
200
|
+
// target was before (a live row vs an archived one), which the plan has
|
|
201
|
+
// already decided; `status = 'active'` covers both.
|
|
202
|
+
updateStmt.bind([
|
|
203
|
+
payload.content,
|
|
204
|
+
payload.type,
|
|
205
|
+
payload.tags,
|
|
206
|
+
payload.metadata,
|
|
207
|
+
payload.embedding,
|
|
208
|
+
payload.embeddingModel,
|
|
209
|
+
payload.embeddingDimensions,
|
|
210
|
+
payload.updatedAt,
|
|
211
|
+
payload.namespace,
|
|
212
|
+
payload.key,
|
|
213
|
+
]);
|
|
214
|
+
updateStmt.step();
|
|
215
|
+
if (db.getRowsModified() > 0) {
|
|
216
|
+
if (action.op === 'resurrect')
|
|
217
|
+
report.resurrected++;
|
|
218
|
+
else
|
|
219
|
+
report.updated++;
|
|
220
|
+
}
|
|
221
|
+
updateStmt.reset();
|
|
222
|
+
}
|
|
223
|
+
db.run('COMMIT');
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
try {
|
|
227
|
+
db.run('ROLLBACK');
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
/* best-effort — close() also discards an open transaction */
|
|
231
|
+
}
|
|
232
|
+
throw e;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
for (const stmt of [insertStmt, updateStmt, archiveStmt]) {
|
|
237
|
+
if (!stmt)
|
|
238
|
+
continue;
|
|
239
|
+
try {
|
|
240
|
+
stmt.free();
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
/* best-effort cleanup */
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return report;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Archive one durable row so the deletion can propagate, returning whether a
|
|
251
|
+
* row was actually affected.
|
|
252
|
+
*
|
|
253
|
+
* The single archive implementation: both `flo memory delete` paths (offline
|
|
254
|
+
* `entries-write.deleteEntry` and daemon `bridgeDeleteEntry`) call this rather
|
|
255
|
+
* than binding the statement themselves, so the bind order and the
|
|
256
|
+
* `status = 'active'` guard exist once. A hard delete in a durable namespace
|
|
257
|
+
* would silently un-share nothing — the entry returns on the next import,
|
|
258
|
+
* which is the #1463 failure mode in miniature.
|
|
259
|
+
*/
|
|
260
|
+
export function archiveDurableRow(db, namespace, key, deletedAt) {
|
|
261
|
+
if (!hasMemoryEntriesTable(db))
|
|
262
|
+
return false;
|
|
263
|
+
db.run(ARCHIVE_DURABLE_ROW_SQL, [deletedAt, namespace, key]);
|
|
264
|
+
return db.getRowsModified() > 0;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Drop archived durable rows whose deletion is older than `ttlMs`.
|
|
268
|
+
*
|
|
269
|
+
* Story #728 retired the previous soft-delete because tombstones were
|
|
270
|
+
* write-only and grew without bound. Both halves of that are answered here: the
|
|
271
|
+
* rows are read (by every sync direction, and by a re-creation that must beat
|
|
272
|
+
* them), and this prune bounds them on the same 90-day window the artifact uses
|
|
273
|
+
* — by which point every store has long since seen the deletion.
|
|
274
|
+
*
|
|
275
|
+
* Returns the number of rows removed.
|
|
276
|
+
*/
|
|
277
|
+
export function pruneExpiredArchives(db, now, ttlMs, namespaces = DURABLE_NAMESPACES) {
|
|
278
|
+
if (!hasMemoryEntriesTable(db))
|
|
279
|
+
return 0;
|
|
280
|
+
// Probe before writing. `idx_memory_status` makes this a cheap index hit, and
|
|
281
|
+
// most stores hold no archived rows at all — running the DELETE regardless
|
|
282
|
+
// would open a write transaction on every session start for nothing.
|
|
283
|
+
const probe = db.exec(`SELECT 1 FROM memory_entries WHERE status = '${ARCHIVED_STATUS}' AND updated_at < ? LIMIT 1`, [now - ttlMs]);
|
|
284
|
+
if (!probe[0]?.values?.[0])
|
|
285
|
+
return 0;
|
|
286
|
+
const placeholders = namespaces.map(() => '?').join(',');
|
|
287
|
+
db.run(`DELETE FROM memory_entries WHERE status = '${ARCHIVED_STATUS}' ` +
|
|
288
|
+
`AND namespace IN (${placeholders}) AND updated_at < ?`, [...namespaces, now - ttlMs]);
|
|
289
|
+
return db.getRowsModified();
|
|
290
|
+
}
|
|
291
|
+
//# sourceMappingURL=durable-store-io.js.map
|