@tpsdev-ai/flair 0.51.1 → 0.52.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/README.md +10 -5
- package/dist/build-info.json +3 -3
- package/dist/cli.js +575 -547
- package/dist/doctor-client.js +35 -0
- package/dist/hook-install.js +74 -0
- package/dist/install/global-bin-path.js +14 -0
- package/dist/lib/auth-resolve.js +15 -0
- package/dist/lib/doctor-run.js +28 -15
- package/dist/lib/upgrade-exec-path.js +257 -0
- package/dist/lib/upgrade-plain-tree.js +558 -0
- package/dist/rem/promote-policy.js +204 -0
- package/dist/rem/restore.js +55 -15
- package/dist/rem/runner.js +203 -20
- package/dist/resources/AdminMemory.js +2 -1
- package/dist/resources/AgentSeed.js +26 -10
- package/dist/resources/Asset.js +203 -0
- package/dist/resources/AutoPromoteCandidates.js +2 -4
- package/dist/resources/Credential.js +14 -0
- package/dist/resources/Federation.js +80 -0
- package/dist/resources/Integration.js +12 -0
- package/dist/resources/Memory.js +158 -60
- package/dist/resources/MemoryBootstrap.js +63 -20
- package/dist/resources/MemoryCandidate.js +12 -0
- package/dist/resources/MemoryConsolidate.js +2 -1
- package/dist/resources/MemoryDedupStats.js +17 -2
- package/dist/resources/MemoryFeed.js +30 -0
- package/dist/resources/MemoryGrant.js +14 -0
- package/dist/resources/MemoryReflect.js +75 -17
- package/dist/resources/Message.js +190 -0
- package/dist/resources/OrgEvent.js +12 -0
- package/dist/resources/PromoteMemoryCandidate.js +76 -0
- package/dist/resources/RecordUsage.js +1 -1
- package/dist/resources/Relationship.js +12 -0
- package/dist/resources/SemanticSearch.js +45 -13
- package/dist/resources/Soul.js +54 -18
- package/dist/resources/WorkspaceState.js +12 -0
- package/dist/resources/auth-middleware.js +17 -44
- package/dist/resources/authority-field-guard.js +37 -0
- package/dist/resources/bm25-index-service.js +1 -1
- package/dist/resources/bm25-index.js +50 -11
- package/dist/resources/embedding-space-guard.js +238 -0
- package/dist/resources/embeddings-provider.js +32 -5
- package/dist/resources/federation-classify.js +23 -1
- package/dist/resources/health.js +11 -2
- package/dist/resources/hit-tracking.js +244 -0
- package/dist/resources/mcp-tools.js +272 -7
- package/dist/resources/memory-reflect-lib.js +111 -0
- package/dist/resources/migrations/embedding-stamp.js +22 -4
- package/dist/resources/owner-field-guard.js +62 -0
- package/dist/resources/promotion-stamp.js +29 -0
- package/dist/resources/record-owner-guard.js +71 -5
- package/dist/resources/record-types.js +30 -7
- package/dist/resources/relay-lib.js +205 -0
- package/dist/resources/relay-ops.js +294 -0
- package/dist/resources/skill-write.js +120 -0
- package/dist/resources/soul-adk-guard.js +68 -0
- package/dist/resources/soul-write-policy.js +63 -0
- package/dist/resources/table-helpers.js +2 -0
- package/dist/resources/usage-recording.js +3 -3
- package/dist/src/rem/promote-policy.js +204 -0
- package/docs/api-reference.md +374 -0
- package/docs/auth.md +52 -0
- package/docs/federation.md +4 -0
- package/docs/integrations.md +6 -6
- package/docs/mcp-clients.md +16 -1
- package/docs/releasing.md +11 -8
- package/docs/rem.md +20 -2
- package/docs/upgrade.md +47 -2
- package/package.json +13 -8
- package/schemas/memory.graphql +51 -2
- package/schemas/message.graphql +74 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Pure promotion policy shared by the CLI and trusted server workflow.
|
|
2
|
+
export function validatePromoteOpts(opts) {
|
|
3
|
+
if (!opts.rationale || !opts.rationale.trim()) {
|
|
4
|
+
return "--rationale is required (per spec § 5: no rubber-stamp)";
|
|
5
|
+
}
|
|
6
|
+
if (!opts.to || (opts.to !== "soul" && opts.to !== "memory")) {
|
|
7
|
+
return "--to must be 'soul' or 'memory'";
|
|
8
|
+
}
|
|
9
|
+
if (opts.to === "soul" && (!opts.key || !opts.key.trim())) {
|
|
10
|
+
return "--key is required when --to=soul (gives the Soul entry a meaningful identifier)";
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
export function validateRejectOpts(opts) {
|
|
15
|
+
if (!opts.reason || !opts.reason.trim()) {
|
|
16
|
+
return "--reason is required";
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Decide whether a promote/reject action can proceed against a candidate's
|
|
22
|
+
* current state, and what message to surface to the operator. Pure function;
|
|
23
|
+
* action side effects happen in the CLI body after this returns ok.
|
|
24
|
+
*/
|
|
25
|
+
export function decideCandidateAction(candidate, action) {
|
|
26
|
+
if (!candidate)
|
|
27
|
+
return { ok: false, severity: "error", message: "candidate not found" };
|
|
28
|
+
const status = candidate.status;
|
|
29
|
+
if (status === "promoted") {
|
|
30
|
+
return action === "promote"
|
|
31
|
+
? { ok: false, severity: "error", message: `already promoted (target=${candidate.target}, reviewer=${candidate.reviewerId})` }
|
|
32
|
+
: { ok: false, severity: "error", message: `already promoted; cannot reject after promotion` };
|
|
33
|
+
}
|
|
34
|
+
if (status === "rejected") {
|
|
35
|
+
return action === "reject"
|
|
36
|
+
? { ok: false, severity: "info", message: `already rejected on ${candidate.decidedAt} by ${candidate.reviewerId}` }
|
|
37
|
+
: { ok: false, severity: "error", message: `already rejected; use a fresh candidate or reset status manually` };
|
|
38
|
+
}
|
|
39
|
+
return { ok: true };
|
|
40
|
+
}
|
|
41
|
+
// ─── ADK tag-lineage on promote (#1205 slice 1205a — Sherlock security req) ───
|
|
42
|
+
// ADK session records are written by adk-flair (memory_service.py) under a
|
|
43
|
+
// SHARED-namespace agentId, with per-user separation carried ENTIRELY by a
|
|
44
|
+
// compound scope tag `adk:<app>:<user>`. That tag is the access-control
|
|
45
|
+
// boundary. A candidate distilled from those records therefore MUST carry the
|
|
46
|
+
// scope tag when promoted, or the promoted claim lands in the shared agentId
|
|
47
|
+
// memory retrievable by every other user of the app — a cross-user leak.
|
|
48
|
+
//
|
|
49
|
+
// `rem promote` historically hard-coded `["nightly-rem-promoted", from:<id>]`
|
|
50
|
+
// and DROPPED the source tag. We now propagate the source scope tag for
|
|
51
|
+
// ADK-sourced candidates, and FAIL CLOSED (refuse) when a candidate is
|
|
52
|
+
// ADK-sourced but its scope tag can't be uniquely+completely determined.
|
|
53
|
+
//
|
|
54
|
+
// SCOPING (deliberate, per spec): fail-closed applies ONLY to ADK-sourced
|
|
55
|
+
// candidates. Non-ADK candidates carry no `adk:` tag and promote byte-for-byte
|
|
56
|
+
// as before — a transient/deleted source on a non-ADK candidate must NOT block
|
|
57
|
+
// its promotion.
|
|
58
|
+
//
|
|
59
|
+
// SEAM (foundation only; the distillation engine is slice #1205b): ADK-sourcing
|
|
60
|
+
// is detected here by re-reading the candidate's source memories and inspecting
|
|
61
|
+
// their tags. That leaves ONE residual fail-open: an ADK-sourced candidate all
|
|
62
|
+
// of whose source memories are unreadable (deleted/transient) yields no `adk:`
|
|
63
|
+
// evidence and is treated as non-ADK. Closing that corner without regressing
|
|
64
|
+
// non-ADK promotion requires the ENGINE to stamp the authoritative scope tag
|
|
65
|
+
// onto the MemoryCandidate row at distillation time (it distills per single
|
|
66
|
+
// scope:tagged tag, so it knows it authoritatively). `derivePromotedTags` is
|
|
67
|
+
// written so that override can be threaded in later without touching callers.
|
|
68
|
+
export const ADK_SCOPE_TAG_PREFIX = "adk:";
|
|
69
|
+
/**
|
|
70
|
+
* Decide the tag set for a promoted Memory given the candidate id and the
|
|
71
|
+
* result of fetching each of its source memories. Pure — no I/O; the action
|
|
72
|
+
* callback does the fetching and threads the results here so this is unit-
|
|
73
|
+
* testable and the fail-closed logic is exercised directly.
|
|
74
|
+
*
|
|
75
|
+
* `stampedScopeTag` (#1205b-1 — the engine slice the #1205a SEAM note below
|
|
76
|
+
* anticipated): the authoritative scope:"tagged" tag the distillation engine
|
|
77
|
+
* stamped onto the MemoryCandidate row (resources/MemoryReflect.ts →
|
|
78
|
+
* buildStagedCandidateRow). When present it is AUTHORITATIVE and short-circuits
|
|
79
|
+
* the source re-read entirely — the engine distilled under exactly this one
|
|
80
|
+
* tag, so it knows the per-user scope tag independent of whether the source
|
|
81
|
+
* memories are still readable. This closes the residual fail-open the SEAM
|
|
82
|
+
* note describes: a candidate all of whose sources are unreadable yields no
|
|
83
|
+
* `adk:` evidence and would otherwise be mis-classified NON-ADK and promoted
|
|
84
|
+
* tagless into the shared agentId namespace (a cross-user leak). Threading it
|
|
85
|
+
* in as an optional trailing arg keeps every pre-#1205b caller (and every
|
|
86
|
+
* candidate that never carried a stamp) on the unchanged source-re-read path.
|
|
87
|
+
*
|
|
88
|
+
* With NO stamp (undefined/empty) the source-re-read classification runs
|
|
89
|
+
* exactly as in #1205a:
|
|
90
|
+
* - No `adk:` scope tag across readable sources → NON-ADK candidate; return
|
|
91
|
+
* the provenance tags only (unchanged behavior).
|
|
92
|
+
* - Exactly one `adk:` scope tag AND every source readable → ADK-sourced;
|
|
93
|
+
* return [scopeTag, ...provenance].
|
|
94
|
+
* - `adk:` evidence present but the scope tag is ambiguous (>1 distinct tag)
|
|
95
|
+
* OR incomplete (some source unreadable) → REFUSE (fail-closed): a
|
|
96
|
+
* tagless/mis-tagged claim in a shared ADK namespace is a cross-user leak,
|
|
97
|
+
* not a benign miss.
|
|
98
|
+
*/
|
|
99
|
+
export function derivePromotedTags(candidateId, sources, stampedScopeTag) {
|
|
100
|
+
const provenance = ["nightly-rem-promoted", `from:${candidateId}`];
|
|
101
|
+
// #1205b-1: a stamped scope tag is AUTHORITATIVE — consume it directly, never
|
|
102
|
+
// re-read sources. This is the seam closure: correctness no longer depends on
|
|
103
|
+
// source readability. `adkSourced` (which gates the Soul-promotion refusal in
|
|
104
|
+
// the promote action) tracks whether the stamped tag is an ADK scope tag.
|
|
105
|
+
if (typeof stampedScopeTag === "string" && stampedScopeTag.length > 0) {
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
tags: [stampedScopeTag, ...provenance],
|
|
109
|
+
adkSourced: stampedScopeTag.startsWith(ADK_SCOPE_TAG_PREFIX),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const adkTags = new Set();
|
|
113
|
+
let anySourceUnreadable = false;
|
|
114
|
+
for (const s of sources) {
|
|
115
|
+
if (!s.ok) {
|
|
116
|
+
anySourceUnreadable = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
for (const t of s.tags) {
|
|
120
|
+
if (typeof t === "string" && t.startsWith(ADK_SCOPE_TAG_PREFIX))
|
|
121
|
+
adkTags.add(t);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// No positive ADK evidence → non-ADK. An unreadable source with zero ADK
|
|
125
|
+
// evidence does NOT fail closed here (that would regress non-ADK promotion);
|
|
126
|
+
// see the SEAM note above.
|
|
127
|
+
if (adkTags.size === 0) {
|
|
128
|
+
return { ok: true, tags: provenance, adkSourced: false };
|
|
129
|
+
}
|
|
130
|
+
if (adkTags.size > 1) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
reason: `ADK-sourced candidate spans multiple scope tags (${[...adkTags].sort().join(", ")}); refusing to promote — a merged cross-user claim would leak across users`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (anySourceUnreadable) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
reason: `ADK-sourced candidate has unreadable source memories; the per-user scope tag cannot be confirmed — refusing to promote (fail-closed)`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const scopeTag = [...adkTags][0];
|
|
143
|
+
return { ok: true, tags: [scopeTag, ...provenance], adkSourced: true };
|
|
144
|
+
}
|
|
145
|
+
// ─── Promoted-row visibility (flair#1257 slice 3 — default-private-unless) ────
|
|
146
|
+
// Continuity-journal scope tag prefix. Canonical string duplicated in
|
|
147
|
+
// resources/memory-reflect-lib.ts / resources/auto-promote-lib.ts and
|
|
148
|
+
// packages/flair-mcp/src/continuity.ts — this file sits on the CLI side of the
|
|
149
|
+
// npm-packaging boundary (see this file's header) and cannot import them; kept
|
|
150
|
+
// in sync by the shared canonical string, same discipline as
|
|
151
|
+
// MACHINE_REVIEWER_* below.
|
|
152
|
+
export const CONTINUITY_SCOPE_TAG_PREFIX = "adk:continuity:";
|
|
153
|
+
/**
|
|
154
|
+
* Decide a promoted Memory row's visibility for the HUMAN `rem promote` path
|
|
155
|
+
* (flair#1257 slice 3). Mirror of resources/auto-promote-lib.ts
|
|
156
|
+
* decidePromotedVisibility (the server-side auto-promote half) — Sherlock's
|
|
157
|
+
* default-private-unless ruling covers BOTH promotion paths: the sources of a
|
|
158
|
+
* continuity candidate are the most sensitive tier (ephemeral+private journal
|
|
159
|
+
* rows), so leaving visibility unset here would let Memory's durability-keyed
|
|
160
|
+
* default widen it to shared ("persistent" defaults shared) — a silent
|
|
161
|
+
* visibility escalation. "shared" only when the candidate is continuity-scoped
|
|
162
|
+
* AND carries the distiller's affirmative ruling WITH its recorded
|
|
163
|
+
* team-relevance justification; every other case — including every
|
|
164
|
+
* uncertainty — is "private".
|
|
165
|
+
*
|
|
166
|
+
* Returns undefined for NON-continuity candidates: their visibility behavior
|
|
167
|
+
* (durability-keyed default) is byte-for-byte the pre-slice-3 contract and is
|
|
168
|
+
* deliberately not changed here.
|
|
169
|
+
*/
|
|
170
|
+
export function derivePromotedVisibility(candidate) {
|
|
171
|
+
const scopeTag = candidate.scopeTag;
|
|
172
|
+
const isContinuity = typeof scopeTag === "string" &&
|
|
173
|
+
scopeTag.length > CONTINUITY_SCOPE_TAG_PREFIX.length &&
|
|
174
|
+
scopeTag.startsWith(CONTINUITY_SCOPE_TAG_PREFIX);
|
|
175
|
+
if (!isContinuity)
|
|
176
|
+
return undefined;
|
|
177
|
+
if (candidate.visibilityRuling !== "shared")
|
|
178
|
+
return "private";
|
|
179
|
+
const rationale = typeof candidate.visibilityRationale === "string" ? candidate.visibilityRationale.trim() : "";
|
|
180
|
+
return rationale.length > 0 ? "shared" : "private";
|
|
181
|
+
}
|
|
182
|
+
// ─── Machine reviewer namespace (#1205 slice 1205a — Sherlock security req 4) ─
|
|
183
|
+
// A promotion records a reviewerId that feeds audit/attribution
|
|
184
|
+
// (schemas/memory.graphql:209). An automated (machine-driven) promotion path
|
|
185
|
+
// must record a reviewerId that can NEVER be mistaken for a human/agent
|
|
186
|
+
// reviewer, so attribution isn't laundered. Reserve the `machine:` namespace
|
|
187
|
+
// for that, and forbid the human `--reviewer` path from claiming it.
|
|
188
|
+
export const MACHINE_REVIEWER_PREFIX = "machine:";
|
|
189
|
+
/** Canonical machine reviewerId for the ADK auto-promote consumer (#1205b). */
|
|
190
|
+
export const MACHINE_REVIEWER_ADK_AUTO_PROMOTE = "machine:adk-auto-promote";
|
|
191
|
+
/** True iff `id` is in the reserved machine-reviewer namespace — i.e. it
|
|
192
|
+
* denotes an automated path, not a human or agent reviewer. */
|
|
193
|
+
export function isMachineReviewerId(id) {
|
|
194
|
+
return typeof id === "string" && id.startsWith(MACHINE_REVIEWER_PREFIX);
|
|
195
|
+
}
|
|
196
|
+
/** The human `flair rem promote` path must not record a reviewerId in the
|
|
197
|
+
* reserved machine namespace — that would launder automated attribution onto
|
|
198
|
+
* a human-operated promotion. Returns an error string, or null if allowed. */
|
|
199
|
+
export function validateHumanReviewerId(reviewerId) {
|
|
200
|
+
if (isMachineReviewerId(reviewerId)) {
|
|
201
|
+
return `--reviewer '${reviewerId}' uses the reserved '${MACHINE_REVIEWER_PREFIX}' namespace (reserved for automated promotion); use a human/agent reviewer id`;
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
package/dist/rem/restore.js
CHANGED
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Approach: client-side. The CLI sequentially calls existing `/Memory` and
|
|
9
9
|
* `/Soul` endpoints (DELETE current rows for the agent, PUT snapshot rows).
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Soul DELETE/PUT must use `soulApiCall` (operator Basic / deliberate
|
|
11
|
+
* internal). Snapshot souls carry `agentId`, so a bare `apiCall` signs as
|
|
12
|
+
* that agent and the source gate 403s. Souls are PUT before memories, and
|
|
13
|
+
* leftover MemoryCandidate rows for the agent are deleted first, so
|
|
14
|
+
* restored identity is not refused as learned content.
|
|
12
15
|
*
|
|
13
16
|
* Reversibility-of-restore guarantee: before any destructive op, this
|
|
14
17
|
* module creates a pre-restore snapshot of the CURRENT state. If something
|
|
@@ -35,6 +38,18 @@ function asArray(raw) {
|
|
|
35
38
|
}
|
|
36
39
|
return [];
|
|
37
40
|
}
|
|
41
|
+
function soulWrite(opts) {
|
|
42
|
+
return opts.soulApiCall ?? opts.apiCall;
|
|
43
|
+
}
|
|
44
|
+
async function listAgentCandidates(apiCall, agentId) {
|
|
45
|
+
return asArray(await apiCall("POST", "/MemoryCandidate/search_by_conditions", {
|
|
46
|
+
operator: "and",
|
|
47
|
+
conditions: [
|
|
48
|
+
{ search_attribute: "agentId", search_type: "equals", search_value: agentId },
|
|
49
|
+
],
|
|
50
|
+
get_attributes: ["id", "claim"],
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
38
53
|
function parseJsonlSafe(text) {
|
|
39
54
|
if (!text.trim())
|
|
40
55
|
return [];
|
|
@@ -52,8 +67,8 @@ function parseJsonlSafe(text) {
|
|
|
52
67
|
* 3. Verify metadata.agentId matches opts.agentId (prevents accidental
|
|
53
68
|
* cross-agent restore — the file might have been hand-copied).
|
|
54
69
|
* 4. Create a pre-restore snapshot of current state (skip in dry-run).
|
|
55
|
-
* 5. Fetch + delete current memories/souls for the agent (skip in dry-run).
|
|
56
|
-
* 6. PUT snapshot
|
|
70
|
+
* 5. Fetch + delete current memories/souls/candidates for the agent (skip in dry-run).
|
|
71
|
+
* 6. PUT snapshot souls, then memories (skip in dry-run).
|
|
57
72
|
* 7. Return counts.
|
|
58
73
|
*
|
|
59
74
|
* On any error after step 4: the result reports `status: "failed"` with
|
|
@@ -65,7 +80,7 @@ export async function applySnapshot(opts) {
|
|
|
65
80
|
status: "completed",
|
|
66
81
|
agentId: opts.agentId,
|
|
67
82
|
snapshotPath: opts.snapshotPath,
|
|
68
|
-
deleted: { memories: 0, souls: 0 },
|
|
83
|
+
deleted: { memories: 0, souls: 0, candidates: 0 },
|
|
69
84
|
restored: { memories: 0, souls: 0 },
|
|
70
85
|
errors,
|
|
71
86
|
};
|
|
@@ -122,12 +137,15 @@ export async function applySnapshot(opts) {
|
|
|
122
137
|
}
|
|
123
138
|
if (opts.dryRun) {
|
|
124
139
|
// In dry-run, report planned counts. Still fetch current state for
|
|
125
|
-
// accurate deleted-counts reporting
|
|
140
|
+
// accurate deleted-counts reporting, including leftover candidates
|
|
141
|
+
// that --apply will wipe before Soul PUT.
|
|
126
142
|
try {
|
|
127
143
|
const currentMem = asArray(await opts.apiCall("GET", `/Memory?agentId=${encodeURIComponent(opts.agentId)}`));
|
|
128
144
|
const currentSouls = asArray(await opts.apiCall("GET", `/Soul?agentId=${encodeURIComponent(opts.agentId)}`));
|
|
145
|
+
const currentCandidates = await listAgentCandidates(opts.apiCall, opts.agentId);
|
|
129
146
|
result.deleted.memories = currentMem.length;
|
|
130
147
|
result.deleted.souls = currentSouls.length;
|
|
148
|
+
result.deleted.candidates = currentCandidates.length;
|
|
131
149
|
result.restored.memories = memories.length;
|
|
132
150
|
result.restored.souls = souls.length;
|
|
133
151
|
result.status = "dry-run";
|
|
@@ -163,7 +181,16 @@ export async function applySnapshot(opts) {
|
|
|
163
181
|
rmSync(tmp, { recursive: true, force: true });
|
|
164
182
|
return result;
|
|
165
183
|
}
|
|
166
|
-
// 5. Delete current memories
|
|
184
|
+
// 5. Delete current memories, souls, and leftover candidates. Candidates
|
|
185
|
+
// are not in the snapshot, but refuseLearnedSoulWrite matches claim text,
|
|
186
|
+
// so a leftover row 403s Soul PUT after operator auth succeeds.
|
|
187
|
+
let currentCandidates = [];
|
|
188
|
+
try {
|
|
189
|
+
currentCandidates = await listAgentCandidates(opts.apiCall, opts.agentId);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
errors.push(`fetch-candidates: ${err?.message ?? String(err)}`);
|
|
193
|
+
}
|
|
167
194
|
for (const m of currentMem) {
|
|
168
195
|
if (!m?.id)
|
|
169
196
|
continue;
|
|
@@ -179,36 +206,49 @@ export async function applySnapshot(opts) {
|
|
|
179
206
|
if (!s?.id)
|
|
180
207
|
continue;
|
|
181
208
|
try {
|
|
182
|
-
await opts
|
|
209
|
+
await soulWrite(opts)("DELETE", `/Soul/${encodeURIComponent(String(s.id))}`);
|
|
183
210
|
result.deleted.souls++;
|
|
184
211
|
}
|
|
185
212
|
catch (err) {
|
|
186
213
|
errors.push(`delete-soul ${s.id}: ${err?.message ?? String(err)}`);
|
|
187
214
|
}
|
|
188
215
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (!m?.id)
|
|
216
|
+
for (const c of currentCandidates) {
|
|
217
|
+
if (!c?.id)
|
|
192
218
|
continue;
|
|
193
219
|
try {
|
|
194
|
-
await opts.apiCall("
|
|
195
|
-
result.
|
|
220
|
+
await opts.apiCall("DELETE", `/MemoryCandidate/${encodeURIComponent(String(c.id))}`);
|
|
221
|
+
result.deleted.candidates++;
|
|
196
222
|
}
|
|
197
223
|
catch (err) {
|
|
198
|
-
errors.push(`
|
|
224
|
+
errors.push(`delete-candidate ${c.id}: ${err?.message ?? String(err)}`);
|
|
199
225
|
}
|
|
200
226
|
}
|
|
227
|
+
// 6. PUT snapshot rows. Souls first: refuseLearnedSoulWrite matches the
|
|
228
|
+
// target agent's Memory text, so memories-then-souls 403s a previously
|
|
229
|
+
// valid snapshot even with operator credentials.
|
|
201
230
|
for (const s of souls) {
|
|
202
231
|
if (!s?.id)
|
|
203
232
|
continue;
|
|
204
233
|
try {
|
|
205
|
-
await opts
|
|
234
|
+
await soulWrite(opts)("PUT", `/Soul/${encodeURIComponent(String(s.id))}`, s);
|
|
206
235
|
result.restored.souls++;
|
|
207
236
|
}
|
|
208
237
|
catch (err) {
|
|
209
238
|
errors.push(`put-soul ${s.id}: ${err?.message ?? String(err)}`);
|
|
210
239
|
}
|
|
211
240
|
}
|
|
241
|
+
for (const m of memories) {
|
|
242
|
+
if (!m?.id)
|
|
243
|
+
continue;
|
|
244
|
+
try {
|
|
245
|
+
await opts.apiCall("PUT", `/Memory/${encodeURIComponent(String(m.id))}`, m);
|
|
246
|
+
result.restored.memories++;
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
errors.push(`put-memory ${m.id}: ${err?.message ?? String(err)}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
212
252
|
// 7. Verify post-restore state (default on; opt-out via verifyPostRestore=false).
|
|
213
253
|
// Catches silent failures: Harper schema coercion, 4xx responses the
|
|
214
254
|
// apiCall layer masked as ok, partial-DELETE leftovers. Per-ID diff,
|