@tpsdev-ai/flair 0.17.0 → 0.19.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.
@@ -1,6 +1,7 @@
1
1
  import { databases } from "@harperfast/harper";
2
- import { resolveAgentAuth } from "./agent-auth.js";
2
+ import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
3
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
4
+ const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
4
5
  /**
5
6
  * Relationship resource — entity-to-entity relationships with temporal validity.
6
7
  *
@@ -13,6 +14,53 @@ import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
13
14
  * Admin agents can query across all agents.
14
15
  */
15
16
  export class Relationship extends databases.flair.Relationship {
17
+ /**
18
+ * Self-authorize now that the global gate is non-rejecting (memory-soul-
19
+ * read-gate family fix, ops-oox7 — same pattern as Memory.ts/Soul.ts/
20
+ * WorkspaceState.ts). Closes the same P0 leak: Harper routes
21
+ * `GET /Relationship/<id>` to get() and the collection describe
22
+ * (`GET /Relationship`) outside search(), so neither was gated before this
23
+ * fix — an anonymous caller got a 200 with full record content. Per-record
24
+ * ownership scoping happens in get() below; the collection scope is still
25
+ * in search().
26
+ */
27
+ allowRead() { return allowVerified(this.getContext?.()); }
28
+ /**
29
+ * Override get() to scope by-id reads the same way search() scopes
30
+ * collection reads (memory-soul-read-gate family fix). Never distinguishes
31
+ * "doesn't exist" from "exists but not yours" — both return 404, never
32
+ * 403, so a denied caller can't use get() to enumerate other agents'
33
+ * relationship ids.
34
+ */
35
+ async get(target) {
36
+ // Collection / query reads arrive as a RequestTarget with
37
+ // `isCollection === true`, and are governed by search() (same owner
38
+ // scoping). Only a genuine by-id get is ownership-checked below — see
39
+ // Memory.ts's get() for the full rationale (same bug class: without this
40
+ // guard, a query's RequestTarget would flow into super.get(), return the
41
+ // whole result set, and the single-record ownership check below would
42
+ // find no `.agentId` on it).
43
+ if (!target || (typeof target === "object" && target.isCollection)) {
44
+ return this.search(target);
45
+ }
46
+ const auth = await resolveAgentAuth(this.getContext?.());
47
+ // Anonymous by-id read is already blocked at the allowRead() gate (403);
48
+ // this is defense-in-depth if get() is ever reached directly.
49
+ if (auth.kind === "anonymous") {
50
+ return NOT_FOUND();
51
+ }
52
+ // Trusted internal call or admin agent — unfiltered, unchanged behavior.
53
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
54
+ return super.get(target);
55
+ }
56
+ // Non-admin agent: only its own relationships.
57
+ const record = await super.get(target);
58
+ if (!record)
59
+ return NOT_FOUND();
60
+ if (record.agentId !== auth.agentId)
61
+ return NOT_FOUND();
62
+ return record;
63
+ }
16
64
  async search(query) {
17
65
  const auth = await resolveAgentAuth(this.getContext?.());
18
66
  // Anonymous HTTP must NOT read relationships (previously `!authAgent` was
@@ -4,6 +4,7 @@ import { getEmbedding, getMode } from "./embeddings-provider.js";
4
4
  import { patchRecord, withDetachedTxn } from "./table-helpers.js";
5
5
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
6
6
  import { wrapUntrusted } from "./content-safety.js";
7
+ import { isRerankEnabled, getRerankTopN, getRerankBudgetMs, getRerankMinCandidates, rerankCandidates, } from "./rerank-provider.js";
7
8
  // Temporal decay + relevance scoring (incl. the OPS-AYGD retrievalBoost cap +
8
9
  // relevance floor) lives in ./scoring.ts — a Harper-free module so it can be
9
10
  // unit-tested directly (see test/unit/temporal-scoring.test.ts).
@@ -170,6 +171,16 @@ export class SemanticSearch extends Resource {
170
171
  }
171
172
  const results = [];
172
173
  const hybrid = hybridEnabled();
174
+ // When the reranker is on, widen the legacy HNSW fetch so it has a deeper
175
+ // pool to re-score (retrieve topN → rerank → slice to limit). Decoupled
176
+ // from CANDIDATE_MULTIPLIER so composite re-ranking keeps its existing
177
+ // headroom. Scoped to the legacy (non-hybrid) vector path below — the
178
+ // hybrid path's candidate pool is already governed by CANDIDATE_MULTIPLIER
179
+ // (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union (ops-i39b); the
180
+ // reranker still applies to its output further down regardless of which
181
+ // path produced `filteredResults`.
182
+ const rerankOn = isRerankEnabled();
183
+ const rerankTopN = getRerankTopN();
173
184
  if (hybrid) {
174
185
  // ─── BM25 + union-RRF hybrid path (ops-i39b) ─────────────────────────
175
186
  // 1. Semantic candidates via HNSW (unchanged fetch). 2. BM25 lexical pass
@@ -284,8 +295,10 @@ export class SemanticSearch extends Resource {
284
295
  }
285
296
  }
286
297
  else if (qEmb) {
287
- // ─── HNSW vector search path ───────────────────────────────────────────
288
- const candidateLimit = limit * CANDIDATE_MULTIPLIER;
298
+ // ─── HNSW vector search path (legacy, hybrid flag OFF) ────────────────
299
+ const candidateLimit = rerankOn
300
+ ? Math.max(limit * CANDIDATE_MULTIPLIER, rerankTopN)
301
+ : limit * CANDIDATE_MULTIPLIER;
289
302
  const query = {
290
303
  sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
291
304
  select: ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
@@ -387,7 +400,32 @@ export class SemanticSearch extends Resource {
387
400
  if (minScore > 0) {
388
401
  filteredResults = filteredResults.filter((r) => r._score >= minScore);
389
402
  }
390
- filteredResults.sort((a, b) => b._score - a._score);
403
+ // ─── Cross-encoder rerank (best-effort, fail-open to vector order) ───────
404
+ // Re-scores query+candidate TOGETHER (cross-attention the pooled embedding
405
+ // can't do) and reorders before the final slice. Reorders whatever
406
+ // `filteredResults` the retrieval stage above produced — legacy HNSW-only
407
+ // OR the BM25+union-RRF hybrid path (ops-i39b) — since both converge into
408
+ // the same `results`/`filteredResults` shape before this point; hybrid
409
+ // retrieves+fuses, the reranker only reorders the fused set. Still gated
410
+ // on `qEmb` (an embedding was actually generated); the pure keyword-only
411
+ // fallback (no qEmb at all) is untouched either way. The reranker
412
+ // overwrites `_score` with the rerank score (so margin measurement reads it)
413
+ // and preserves the semantic score as `_semScore`; `_rawScore` is left as-is
414
+ // so recall-bench's scoring:"raw" path stays reproducible. On init failure,
415
+ // timeout, or any throw, rerankCandidates returns the input UNCHANGED and we
416
+ // fall through to the existing vector-order sort — recall is never blocked.
417
+ if (rerankOn && qEmb && q && filteredResults.length >= getRerankMinCandidates()) {
418
+ // Pre-sort by vector order so the topN fed to the reranker is the most
419
+ // semantically-promising slice (filteredResults isn't sorted yet here).
420
+ filteredResults.sort((a, b) => b._score - a._score);
421
+ filteredResults = await rerankCandidates(String(q), filteredResults, {
422
+ topN: rerankTopN,
423
+ budgetMs: getRerankBudgetMs(),
424
+ });
425
+ }
426
+ else {
427
+ filteredResults.sort((a, b) => b._score - a._score);
428
+ }
391
429
  const topResults = filteredResults.slice(0, limit);
392
430
  // Async hit tracking — don't block the response
393
431
  const now = new Date().toISOString();
@@ -1,5 +1,5 @@
1
1
  import { databases } from "@harperfast/harper";
2
- import { resolveAgentAuth } from "./agent-auth.js";
2
+ import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
3
  const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
4
4
  const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
5
5
  /**
@@ -19,6 +19,17 @@ async function enforceWriteAuth(self, data) {
19
19
  return null;
20
20
  }
21
21
  export class Soul extends databases.flair.Soul {
22
+ /**
23
+ * Self-authorize now that the global gate is non-rejecting. Closes the P0
24
+ * leak: Harper routes `GET /Soul/<id>` to get() and the collection
25
+ * describe (`GET /Soul`) to a path outside search()/allow* — neither was
26
+ * gated before this fix, so an anonymous caller got a 200 with full soul
27
+ * content. Deliberately NO get() override / per-agent scoping on top of
28
+ * this: souls are identity/discovery data, intentionally readable by any
29
+ * verified agent — same posture as Agent.ts's allowRead. Write ownership
30
+ * is unaffected — enforceWriteAuth() below already gates post()/put().
31
+ */
32
+ allowRead() { return allowVerified(this.getContext?.()); }
22
33
  async post(content, context) {
23
34
  const denied = await enforceWriteAuth(this, content);
24
35
  if (denied)
@@ -8,15 +8,64 @@
8
8
  * Use this.getContext() to access request context (tpsAgent, tpsAgentIsAdmin).
9
9
  */
10
10
  import { databases } from "@harperfast/harper";
11
- import { resolveAgentAuth } from "./agent-auth.js";
11
+ import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
12
12
  const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
13
13
  const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
14
+ const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
14
15
  export class WorkspaceState extends databases.flair.WorkspaceState {
15
16
  /** Auth verdict from the request context. internal = trusted in-process call;
16
17
  * agent = verified Ed25519; anonymous = HTTP with no valid agent → deny. */
17
18
  _auth() {
18
19
  return resolveAgentAuth(this.getContext?.());
19
20
  }
21
+ /**
22
+ * Self-authorize now that the global gate is non-rejecting (memory-soul-
23
+ * read-gate family fix, ops-oox7 — applying the Memory.ts/Soul.ts pattern
24
+ * to WorkspaceState/Relationship/Integration/MemoryGrant). Closes the same
25
+ * P0 leak: Harper routes `GET /WorkspaceState/<id>` to get() and the
26
+ * collection describe (`GET /WorkspaceState`) to a path outside search(),
27
+ * so neither was gated before this fix — an anonymous caller got a 200 with
28
+ * full record content. Per-record ownership scoping happens in get() below;
29
+ * the collection scope is still in search().
30
+ */
31
+ allowRead() { return allowVerified(this.getContext?.()); }
32
+ /**
33
+ * Override get() to scope by-id reads the same way search() scopes
34
+ * collection reads (memory-soul-read-gate family fix). Never distinguishes
35
+ * "doesn't exist" from "exists but not yours" — both return 404, never
36
+ * 403, so a denied caller can't use get() to enumerate other agents'
37
+ * workspace-state ids.
38
+ */
39
+ async get(target) {
40
+ // Collection / query reads — the `GET /WorkspaceState/?<query>` form and
41
+ // the bare collection — arrive as a RequestTarget with `isCollection ===
42
+ // true`, and are governed by search() (same owner scoping). Only a
43
+ // genuine by-id get is ownership-checked below. Without this guard,
44
+ // get() would receive the query's RequestTarget, super.get() would
45
+ // return the (truthy) result set, and the single-record ownership check
46
+ // would find no `.agentId` on it (see Memory.ts's get() for the full
47
+ // rationale — same bug class).
48
+ if (!target || (typeof target === "object" && target.isCollection)) {
49
+ return this.search(target);
50
+ }
51
+ const auth = await this._auth();
52
+ // Anonymous by-id read is already blocked at the allowRead() gate (403);
53
+ // this is defense-in-depth if get() is ever reached directly.
54
+ if (auth.kind === "anonymous") {
55
+ return NOT_FOUND();
56
+ }
57
+ // Trusted internal call or admin agent — unfiltered, unchanged behavior.
58
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
59
+ return super.get(target);
60
+ }
61
+ // Non-admin agent: only its own workspace-state records.
62
+ const record = await super.get(target);
63
+ if (!record)
64
+ return NOT_FOUND();
65
+ if (record.agentId !== auth.agentId)
66
+ return NOT_FOUND();
67
+ return record;
68
+ }
20
69
  /**
21
70
  * Override search() to scope collection GETs to the authenticated agent's own
22
71
  * records. Internal calls + admin agents see all; anonymous is denied
@@ -85,7 +134,12 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
85
134
  if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
86
135
  return super.delete(id);
87
136
  }
88
- const record = await this.get(id);
137
+ // Use super.get(id), NOT this.get(id): the new get() override above 404s
138
+ // (a truthy Response) for a non-owner id, which would otherwise defeat
139
+ // the `if (!record)` check below and mis-route a genuinely-missing
140
+ // record into the FORBIDDEN branch instead of a clean super.delete(id)
141
+ // no-op. Mirrors Memory.ts's delete() — same rationale, same fix.
142
+ const record = await super.get(id);
89
143
  if (!record)
90
144
  return super.delete(id);
91
145
  if (record.agentId !== auth.agentId) {
@@ -0,0 +1,62 @@
1
+ // ─── Conservative near-duplicate detection primitives (Harper-free) ─────────
2
+ // Memory-integrity fix (#526/#548 field regressions). This module is
3
+ // deliberately Harper-free — same rationale as ./bm25.ts and ./scoring.ts —
4
+ // so the cosine+Jaccard co-gate math is unit-testable directly against the
5
+ // SHIPPED code without a live Harper. The DB-touching top-1-cosine lookup
6
+ // (needs `databases`) lives in ./Memory.ts, built on top of these primitives.
7
+ //
8
+ // ── THE INVARIANT ────────────────────────────────────────────────────────────
9
+ // This module NEVER decides whether to write. It only scores a candidate
10
+ // match. Callers (Memory.post / Memory.put) always write the new record; a
11
+ // "conservative match" is surfaced to the caller as a SIGNAL on the response
12
+ // (deduplicated/matchedId/matchConfidence), never as a reason to skip the
13
+ // write. That was the #526 bug: two topically-close but DISTINCT findings —
14
+ // one about replication route-directionality, one about DDL/schema
15
+ // replication — and the second was silently dropped because raw cosine alone
16
+ // flagged it as a "duplicate" of the first. Requiring BOTH cosine AND lexical
17
+ // (Jaccard token-overlap) to clear their thresholds against the SAME single
18
+ // top-cosine candidate catches true near-dups while sparing topic collisions
19
+ // (high cosine, low lexical) — and even then, only ever as a non-suppressing
20
+ // signal.
21
+ import { tokenize } from "./bm25.js";
22
+ /** Default raw-cosine similarity threshold for a candidate to even be considered. */
23
+ export const DEDUP_COSINE_THRESHOLD_DEFAULT = 0.95;
24
+ /** Default Jaccard token-overlap threshold (of the top cosine candidate). */
25
+ export const DEDUP_LEXICAL_THRESHOLD_DEFAULT = 0.5;
26
+ /** Below this content length, similarity scoring is unreliable ("ok" would
27
+ * match "ok" trivially) — the dedup gate is bypassed entirely. */
28
+ export const DEDUP_MIN_CONTENT_LENGTH = 20;
29
+ /** Jaccard similarity of two token sets: |A∩B| / |A∪B|. An empty side yields 0
30
+ * (no signal — never treated as "fully similar" by vacuous set equality). */
31
+ export function jaccardSimilarity(tokensA, tokensB) {
32
+ const a = new Set(tokensA);
33
+ const b = new Set(tokensB);
34
+ if (a.size === 0 || b.size === 0)
35
+ return 0;
36
+ let intersection = 0;
37
+ for (const t of a)
38
+ if (b.has(t))
39
+ intersection++;
40
+ const union = a.size + b.size - intersection;
41
+ return union === 0 ? 0 : intersection / union;
42
+ }
43
+ /**
44
+ * Conservative match = cosine AND lexical BOTH clear their thresholds.
45
+ * Both gates are required — a topic collision (high cosine, low lexical: two
46
+ * DIFFERENT findings that merely share a topic/vocabulary) must NOT be
47
+ * flagged as a duplicate. Only a true near-duplicate (high cosine AND high
48
+ * lexical overlap) is flagged.
49
+ */
50
+ export function isConservativeMatch(cosine, lexical, cosineThreshold = DEDUP_COSINE_THRESHOLD_DEFAULT, lexicalThreshold = DEDUP_LEXICAL_THRESHOLD_DEFAULT) {
51
+ return cosine >= cosineThreshold && lexical >= lexicalThreshold;
52
+ }
53
+ /** Compute the {cosine, lexical} confidence pair for a candidate against the
54
+ * new content's text, using the shared bm25 tokenizer. Rounded to 3dp to
55
+ * match the existing _score rounding convention (SemanticSearch.ts). */
56
+ export function computeMatchConfidence(newContent, candidateContent, cosine) {
57
+ const lexical = jaccardSimilarity(tokenize(newContent), tokenize(candidateContent || ""));
58
+ return {
59
+ cosine: Math.round(cosine * 1000) / 1000,
60
+ lexical: Math.round(lexical * 1000) / 1000,
61
+ };
62
+ }
@@ -2,6 +2,7 @@ import { Resource, databases } from "@harperfast/harper";
2
2
  import { promises as fsp } from "node:fs";
3
3
  import { homedir, platform } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { getRerankStatus } from "./rerank-provider.js";
5
6
  const db = databases;
6
7
  const redactHome = (p) => {
7
8
  const home = homedir();
@@ -515,6 +516,22 @@ export class HealthDetail extends Resource {
515
516
  catch {
516
517
  stats.bridges = null;
517
518
  }
519
+ // ── Reranker ──
520
+ // Cross-encoder rerank stage. Reports flag/model/mode + live counters so we
521
+ // can see if it's silently degrading (fallbackCount climbing) in prod.
522
+ try {
523
+ const rr = getRerankStatus();
524
+ stats.rerank = rr;
525
+ if (rr.enabled && rr.state === "failed") {
526
+ warnings.push({ level: "warn", message: `reranker enabled but unavailable: ${rr.error ?? "init failed"} — recall falling back to vector order` });
527
+ }
528
+ if (rr.enabled && rr.rerankCount > 0 && rr.fallbackCount > rr.rerankCount) {
529
+ warnings.push({ level: "warn", message: `reranker falling back more than reranking (${rr.fallbackCount} fallbacks / ${rr.rerankCount} reranks) — check latency budget / topN` });
530
+ }
531
+ }
532
+ catch {
533
+ stats.rerank = null;
534
+ }
518
535
  // ── Warnings ──
519
536
  stats.warnings = warnings;
520
537
  // ── Process info ──
@@ -1,17 +1,17 @@
1
1
  /**
2
- * mcp-tools.ts — the 9 curated flair tools for the Model-2 custom /mcp handler.
2
+ * mcp-tools.ts — the 10 curated flair tools for the Model-2 custom /mcp handler.
3
3
  *
4
- * Curated BY CONSTRUCTION: this module implements exactly the 9 tools that the
4
+ * Curated BY CONSTRUCTION: this module implements exactly the 10 tools that the
5
5
  * `@tpsdev-ai/flair-mcp` stdio proxy exposes, each a thin wrapper over the
6
6
  * existing flair Resource handler. No business logic is re-implemented — the
7
7
  * wrapped handlers (Memory / SemanticSearch / BootstrapMemories / Soul /
8
8
  * WorkspaceState / OrgEvent) enforce per-agent scoping/ownership via
9
9
  * `resolveAgentAuth(getContext())`, so the MCP surface inherits the SAME security
10
10
  * model as the signed-REST path. There is no raw CRUD surface — the only way to
11
- * reach the datastore through /mcp is via one of these 9 semantic tools.
11
+ * reach the datastore through /mcp is via one of these 10 semantic tools.
12
12
  *
13
- * memory_search · memory_store · memory_get · memory_delete · bootstrap ·
14
- * soul_set · soul_get · flair_workspace_set · flair_orgevent
13
+ * memory_search · memory_store · memory_update · memory_get · memory_delete ·
14
+ * bootstrap · soul_set · soul_get · flair_workspace_set · flair_orgevent
15
15
  *
16
16
  * ── The scoping seam ────────────────────────────────────────────────────────
17
17
  * The /mcp handler resolves the OAuth token's `sub` → a flair `Agent` id, then
@@ -116,6 +116,58 @@ async function memoryStore(agent, args) {
116
116
  tags: args?.tags,
117
117
  }));
118
118
  }
119
+ /**
120
+ * memory_update — id-targeted, dedup-BYPASSED overwrite/version path (memory-
121
+ * integrity fix). Mirrors flair-client's MemoryApi.update() (packages/
122
+ * flair-client/src/client.ts), reimplemented against the resource instance
123
+ * API instead of HTTP since this handler calls the Memory resource directly
124
+ * (same pattern as memoryStore vs the flair-mcp stdio tool). Auth is enforced
125
+ * by Memory.get()/Memory.put()/Memory.post()'s EXISTING ownership checks — no
126
+ * parallel auth logic here.
127
+ *
128
+ * Default (preserveHistory unset/false): read the existing record, merge the
129
+ * new content on top (Harper PUT is full-record replacement — never send a
130
+ * bare partial), clear the stale embedding so the server regenerates it, and
131
+ * PUT the merged record back to the SAME id.
132
+ *
133
+ * preserveHistory: true: write a NEW id with `supersedes: id`. Memory.post()
134
+ * validates/authorizes the supersede (denying a cross-agent supersede without
135
+ * a "write" MemoryGrant) and closes the old record's validTo AFTER the new
136
+ * record is written (never the reverse — see resources/Memory.ts).
137
+ */
138
+ async function memoryUpdate(agent, args) {
139
+ const Cls = await handler("Memory");
140
+ const h = new Cls(undefined, delegationContext(agent));
141
+ const id = args?.id;
142
+ const content = args?.content;
143
+ const preserveHistory = args?.preserveHistory === true;
144
+ const existing = await h.get(id);
145
+ if (!existing) {
146
+ return { error: "memory not found", status: 404 };
147
+ }
148
+ if (preserveHistory) {
149
+ const newId = `${agent.agentId}-${crypto.randomUUID()}`;
150
+ const record = {
151
+ ...existing,
152
+ id: newId,
153
+ content,
154
+ supersedes: id,
155
+ createdAt: new Date().toISOString(),
156
+ };
157
+ delete record.updatedAt;
158
+ delete record.embedding;
159
+ delete record.embeddingModel;
160
+ delete record.validFrom;
161
+ delete record.validTo;
162
+ delete record.archivedAt;
163
+ h.isCollection = true;
164
+ return unwrap(await h.post(record));
165
+ }
166
+ const merged = { ...existing, content, updatedAt: new Date().toISOString() };
167
+ delete merged.embedding;
168
+ delete merged.embeddingModel;
169
+ return unwrap(await h.put(merged));
170
+ }
119
171
  async function memoryGet(agent, args) {
120
172
  const Cls = await handler("Memory");
121
173
  const h = new Cls(undefined, delegationContext(agent));
@@ -231,6 +283,24 @@ export const TOOLS = {
231
283
  },
232
284
  impl: memoryStore,
233
285
  },
286
+ memory_update: {
287
+ def: {
288
+ name: "memory_update",
289
+ description: "Update an existing memory by ID. Dedup-bypassed (this is an intentional overwrite, not a new write). " +
290
+ "Default: overwrites the same id in place. Pass preserveHistory=true to instead write a new version " +
291
+ "linked via `supersedes`, closing the old one's validity window.",
292
+ inputSchema: {
293
+ type: "object",
294
+ properties: {
295
+ id: { type: "string", description: "ID of the memory to update" },
296
+ content: { type: "string", description: "New content" },
297
+ preserveHistory: { type: "boolean", description: "Write a new version (supersedes-linked) instead of overwriting in place (default false)" },
298
+ },
299
+ required: ["id", "content"],
300
+ },
301
+ },
302
+ impl: memoryUpdate,
303
+ },
234
304
  memory_get: {
235
305
  def: {
236
306
  name: "memory_get",