Skip to content

feat: v0.6.1 — citations, cascading updates, expanded mesh#79

Merged
rohitg00 merged 2 commits intomainfrom
feat/v0.6.1-citations-cascade-mesh
Mar 25, 2026
Merged

feat: v0.6.1 — citations, cascading updates, expanded mesh#79
rohitg00 merged 2 commits intomainfrom
feat/v0.6.1-citations-cascade-mesh

Conversation

@rohitg00
Copy link
Copy Markdown
Owner

@rohitg00 rohitg00 commented Mar 18, 2026

Summary

Type Changes

  • Memory: added sourceObservationIds?: string[]
  • CompressedObservation: added confidence?: number (0-1)
  • GraphNode, GraphEdge: added stale?: boolean
  • MeshPeer: added syncFilter?: { project?: string }

New Files

  • src/functions/verify.ts (~110 lines)
  • src/functions/cascade.ts (~80 lines)
  • test/verify.test.ts (6 tests)
  • test/cascade.test.ts (8 tests)

Modified Files

  • src/functions/compress.ts: sets confidence: qualityScore / 100
  • src/functions/remember.ts: populates sourceObservationIds, fires cascade on supersede
  • src/functions/consolidate.ts: populates sourceObservationIds from observation group
  • src/functions/mesh.ts: 7 scopes, syncFilter, generic LWW merge
  • src/mcp/tools-registry.ts: memory_verify tool (38 total)
  • src/mcp/server.ts: dispatch case for verify
  • src/triggers/api.ts: POST /verify, POST /cascade-update, expanded mesh-export
  • src/index.ts: register new functions

Test plan

  • npx vitest run test/verify.test.ts — 6 tests pass
  • npx vitest run test/cascade.test.ts — 8 tests pass
  • npx vitest run test/mesh.test.ts — 30 tests pass (12 new)
  • npx vitest run --exclude test/integration.test.ts — 573 tests pass across 49 files
  • npx tsc --noEmit — no new type errors
  • CodeRabbit review — 5 findings fixed (null safety + performance optimization)

Summary by CodeRabbit

  • New Features

    • Memory and observation verification (mem::verify) and a memory-verify tool.
    • Cascade updates to mark stale graph nodes/edges when memories are superseded.
    • Compressed observations include a confidence score.
    • Memories record source observation IDs.
    • Mesh sync supports optional project filtering; graph items can be flagged stale.
    • New API endpoints: /agentmemory/verify and /agentmemory/cascade-update.
  • Chores

    • Bumped release to v0.6.1 and added export format support for 0.6.1.

Add JIT verification with citation chains, self-organizing memory via
cascading staleness propagation, and expand mesh sync to 7 scopes
(semantic, procedural, relations, graph nodes/edges).

- mem::verify traces memory/observation provenance back to source
- mem::cascade-update flags stale graph items and sibling memories
- Mesh default scopes expanded from 2 to 7 with project-based filtering
- Memory.sourceObservationIds, CompressedObservation.confidence fields
- GraphNode.stale, GraphEdge.stale for staleness tracking
- MeshPeer.syncFilter for project-scoped action sync
- 573 tests passing across 49 files
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 18, 2026

📝 Walkthrough

Walkthrough

Adds mem::cascade-update and mem::verify functions, propagates sourceObservationIds through memories, marks graph nodes/edges stale on supersession, adds confidence to compressed observations, expands mesh sync to include semantic/procedural/graph scopes with LWW merges, updates types/version to 0.6.1, and adds tests and API/mcp hooks.

Changes

Cohort / File(s) Summary
Cascade & Verify
src/functions/cascade.ts, src/functions/verify.ts
New registrations: registerCascadeFunction and registerVerifyFunction. cascade validates supersededMemoryId, flags stale graph nodes/edges, counts sibling memories; verify returns memory or observation with resolved citations.
Remember & Consolidation
src/functions/remember.ts, src/functions/consolidate.ts
Adds sourceObservationIds to mem input and Memory records; propagates sourceObservationIds during consolidation; remember triggers mem::cascade-update when superseding.
Compression & Types
src/functions/compress.ts, src/types.ts
Adds confidence?: number to CompressedObservation and populates confidence = qualityScore/100; updates types with sourceObservationIds, stale? on graph entities, and syncFilter on MeshPeer.
Mesh Sync Refactor
src/functions/mesh.ts
Introduces MeshSyncPayload, DEFAULT_SHARED_SCOPES, lwwMergeList/lwwMergeGraphNodes, delta filtering, and expands collect/apply sync to semantic, procedural, relations, graphNodes, graphEdges with per-item LWW merging.
API, MCP & Registration
src/triggers/api.ts, src/mcp/server.ts, src/mcp/tools-registry.ts, src/index.ts
Adds api::verify and api::cascade-update endpoints, new MCP tool memory_verify, registers new functions at startup, and exposes cascade/verify via MCP/API.
Graph Retrieval & Querying
src/functions/graph-retrieval.ts, src/functions/graph.ts
Exclude items marked stale from graph search/expand/query paths.
Export/Version
src/functions/export-import.ts, src/version.ts
Adds support for export version 0.6.1 and bumps default VERSION to 0.6.1.
Tests & Mocks
test/cascade.test.ts, test/verify.test.ts, test/mesh.test.ts, test/export-import.test.ts, test/mcp-standalone.test.ts, test/helpers/mocks.ts
Adds cascade and verify tests, expands mesh tests for new scopes and LWW behavior, updates version/tool expectations, and adds mockKV/mockSdk test helpers.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant SDK
  participant KV
  participant GraphStore as Graph (nodes/edges)

  Client->>SDK: trigger "mem::cascade-update" { supersededMemoryId }
  SDK->>KV: get memory by id
  alt memory not found
    KV-->>SDK: null
    SDK-->>Client: error (not found)
  else memory found
    KV-->>SDK: supersededMemory
    SDK->>KV: list graphNodes
    KV-->>SDK: nodes[]
    SDK->>GraphStore: mark nodes stale where sourceObservationIds intersect
    SDK->>KV: update node timestamps
    SDK->>KV: list graphEdges
    KV-->>SDK: edges[]
    SDK->>GraphStore: mark edges stale where sourceObservationIds intersect
    SDK->>KV: update edges
    SDK->>KV: list memories (latest)
    KV-->>SDK: memories[]
    SDK->>KV: update siblingMemories counts for shared concepts
    SDK-->>Client: result { success, counts, total }
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hop through mems both old and new,
I flag the stale and count the few,
I fetch citations, stitch the chain,
Confidence, sources — all remain,
Hooray for version 0.6.1, whoo! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the three main features introduced: citations (via mem::verify), cascading updates (via mem::cascade-update), and expanded mesh synchronization, matching the substantial changes across the codebase.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/v0.6.1-citations-cascade-mesh
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/triggers/api.ts (1)

1474-1483: ⚠️ Potential issue | 🟠 Major

Pull exports still ignore syncFilter.project.

This endpoint only accepts since; there is no way for mesh-sync to request a project-scoped export. A peer registered with { project: ... } will therefore receive the remote instance's full pull dataset.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/triggers/api.ts` around lines 1474 - 1483, The endpoint currently only
honors the "since" query and then computes sinceTime and df (the filter
function) but ignores any syncFilter.project; update request handling to
accept/parse the peer's project filter (e.g., from req.query_params or the
incoming syncFilter payload) and apply it before/alongside the since filter:
read the project identifier, compute sinceTime as now, and modify the df
function (or the filtering pipeline that uses df) to also require that (item as
Record<string, unknown>).project === syncFilter.project (or matches the provided
project param) so returned pulls are scoped to the requested project as well as
the since time. Ensure you reference the existing symbols since, sinceTime, df
and req.query_params when making the change.
🧹 Nitpick comments (5)
test/mcp-standalone.test.ts (1)

25-27: Also assert that memory_verify is exposed.

The exact total catches catalog-size drift, but not whether the new tool is actually present. A name-level assertion will fail with a much better signal.

Suggested tweak
   it("getAllTools returns 38 tools", () => {
     const tools = getAllTools();
     expect(tools.length).toBe(38);
+    expect(tools.map((tool) => tool.name)).toContain("memory_verify");
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/mcp-standalone.test.ts` around lines 25 - 27, The test currently only
asserts the tools array length; update the "getAllTools returns 38 tools" test
to also assert that the returned array from getAllTools contains a tool named
"memory_verify" (e.g., use tools.some(t => t.name === 'memory_verify') or an
equivalent name-level assertion) so the test fails if the specific tool is not
exposed even if the total count matches; modify the test body that calls
getAllTools() and add the additional expect check for the "memory_verify" name.
test/mesh.test.ts (1)

105-114: Please cover actual syncFilter behavior.

This only proves the field is persisted. A push/pull regression that ignores the filter would still pass, so a mixed-project sync test would be valuable here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/mesh.test.ts` around lines 105 - 114, Add an integration test (or extend
the existing "stores syncFilter when provided" scenario) that verifies
syncFilter actually gates push/pull: use sdk.trigger("mem::mesh-register") to
register two peers (one with syncFilter: { project: "/my/project" } and one
without or with a different project), create/insert test documents in both
projects, invoke the mesh push/pull/sync operations used elsewhere in tests (the
same APIs your suite uses for pushing and pulling), and assert that the peer
with syncFilter only receives documents matching { project: "/my/project" }
while documents from the other project are not transferred; reference the
existing MeshPeer.syncFilter field and the sdk.trigger registration flow to
locate where to add these assertions.
test/cascade.test.ts (2)

12-47: Consider extracting shared test utilities to reduce duplication.

The mockKV() and mockSdk() helper functions are identical to those in verify.test.ts. Extracting these to a shared test utility file (e.g., test/helpers/mocks.ts) would reduce duplication and make maintenance easier.

Additionally, using the loose Function type on line 34 loses type safety. Consider using a more specific function signature.

♻️ Suggested approach

Create a shared test helper:

// test/helpers/mocks.ts
export function mockKV() {
  const store = new Map<string, Map<string, unknown>>();
  return {
    get: async <T>(scope: string, key: string): Promise<T | null> => {
      return (store.get(scope)?.get(key) as T) ?? null;
    },
    set: async <T>(scope: string, key: string, data: T): Promise<T> => {
      if (!store.has(scope)) store.set(scope, new Map());
      store.get(scope)!.set(key, data);
      return data;
    },
    delete: async (scope: string, key: string): Promise<void> => {
      store.get(scope)?.delete(key);
    },
    list: async <T>(scope: string): Promise<T[]> => {
      const entries = store.get(scope);
      return entries ? (Array.from(entries.values()) as T[]) : [];
    },
  };
}

export function mockSdk() {
  const functions = new Map<string, (data: unknown) => Promise<unknown>>();
  return {
    registerFunction: (opts: { id: string }, handler: (data: unknown) => Promise<unknown>) => {
      functions.set(opts.id, handler);
    },
    registerTrigger: () => {},
    trigger: async (id: string, data: unknown) => {
      const fn = functions.get(id);
      if (!fn) throw new Error(`No function: ${id}`);
      return fn(data);
    },
    triggerVoid: () => {},
  };
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/cascade.test.ts` around lines 12 - 47, Extract the duplicated mock
helpers into a shared module (e.g., export mockKV and mockSdk from
test/helpers/mocks.ts) and replace the local implementations in cascade.test.ts
and verify.test.ts with imports from that helper; for mockSdk, tighten the loose
Function type to a specific signature like (data: unknown) => Promise<unknown>
(use the same signature for registerFunction's handler and the Map value) so
type safety is preserved, and update the tests to import { mockKV, mockSdk }
from the new helper.

57-57: The as never cast suppresses type checking.

Using as never to bypass type mismatches between the mock and the actual SDK/KV interfaces may hide real incompatibilities. Consider defining a minimal interface type for the mock that captures only the methods used by registerCascadeFunction, which would provide better type safety while still allowing the mock.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/cascade.test.ts` at line 57, The test is using `as never` to silence
type errors when calling registerCascadeFunction(sdk as never, kv as never);
replace the unsafe casts by declaring minimal mock interfaces that include only
the methods/properties used by registerCascadeFunction (e.g., create a local
type like MinimalSdk { methodA(...): ReturnType; ... } and MinimalKv { get(...):
Promise<T>; put(...): Promise<void>; }), type the mock objects (`sdk` and `kv`)
with those interfaces, and then call registerCascadeFunction(sdk as unknown as
MinimalSdk, kv as unknown as MinimalKv) or simply cast to the minimal types
directly so TypeScript enforces the required shape without suppressing checks
for unrelated members.
test/verify.test.ts (1)

33-46: Minor inconsistency with mockSdk in cascade.test.ts.

This mockSdk() implementation lacks the triggerVoid method that exists in cascade.test.ts. While not a bug (since verify tests don't use it), this inconsistency reinforces the benefit of extracting these helpers to a shared module with a single source of truth.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/verify.test.ts` around lines 33 - 46, The mockSdk implementation in
verify.test.ts is missing the triggerVoid helper present in cascade.test.ts;
update mockSdk (the mockSdk function and its returned object) to include a
triggerVoid method with the same semantics as cascade.test.ts (accepting id and
data, invoking the registered function without returning a value) or extract
mockSdk into a shared test helper module and import it into both tests so both
test files use the same implementation; reference the mockSdk function and
triggerVoid when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/functions/cascade.ts`:
- Around line 25-46: The cascade marks GraphNode/GraphEdge items with stale =
true but graph-retrieval.ts still expands from all graph items; update the
retrieval/traversal code (the function that lists/expands GraphNode and
GraphEdge around lines 117-127) to ignore items where stale === true (e.g.,
after kv.list<GraphNode>(KV.graphNodes) and kv.list<GraphEdge>(KV.graphEdges)
apply .filter(node => !node.stale) / .filter(edge => !edge.stale)) so retired
facts are not included in traversal/expansion.

In `@src/functions/consolidate.ts`:
- Line 161: The current mapping const obsIds = top.map((o) => o.id) can produce
duplicate IDs when a concept repeats; before passing IDs to persistence or
mem::verify, deduplicate them (e.g. replace the direct map with a unique array
like Array.from(new Set(top.map(o => o.id)))) so sourceObservationIds contains
only unique IDs; apply the same deduplication to the other similar mapping(s)
around the 177-189 region so that mem::verify and any citationCount logic
receive de-duplicated IDs.

In `@src/functions/mesh.ts`:
- Around line 277-278: The graph sync currently uses createdAt as both the LWW
clock and delta cursor (seen in calls to lwwMergeList with
KV.graphNodes/KV.graphEdges and tags "mem:gnode"/"mem:gedge"), which prevents
later updates from being exported/overwritten; change the merge/delta key to use
a true modification clock (e.g., updatedAt/modifiedAt or a separate
lastModified/mtime field) instead of createdAt in those lwwMergeList calls and
any other graph sync calls (the other graph merge sites referenced in the
review), and ensure upstream writers update that modified timestamp when
nodes/edges are changed so LWW merging and delta export work correctly.
- Around line 321-351: The syncFilter.project is only applied to the "actions"
branch leaving other scopes (semantic, procedural, relations, graph:nodes,
graph:edges) unfiltered; update the code paths that build result.* (references:
syncFilter.project, scopes, KV.semantic, KV.procedural, KV.relations,
KV.graphNodes, KV.graphEdges, deltaFilter, GraphNode, GraphEdge, MemoryRelation)
so that either (A) you derive/attach project ownership for records that can
carry it and filter those lists by record.project when syncFilter.project is set
before calling deltaFilter, or (B) when syncFilter.project is present and a
scope does not have a project field (e.g., GraphNode/GraphEdge), reject that
scope early (throw or return a clear error) instead of returning unscoped data;
implement the chosen approach consistently for semantic, procedural, relations,
graph:nodes and graph:edges branches.

In `@src/functions/remember.ts`:
- Line 17: Validate and normalize the new public field sourceObservationIds
before persisting it: ensure in the remember flow (where sourceObservationIds is
read/assigned alongside files and concepts in src/functions/remember.ts) that
the value is either undefined or an Array of strings—reject or coerce other
types (e.g., if it's a single string, wrap it; if mixed/non-string entries,
filter to strings) and return a 4xx error or strip and log when invalid; this
prevents downstream failures in mem::verify and mem::cascade-update which expect
an array of string IDs.

In `@src/triggers/api.ts`:
- Around line 1481-1500: The current delta filter function df(items, field) uses
"createdAt" for graphNodes and graphEdges so later in-place mutations (like
stale flags) won't be returned; change the returned object to call
df(graphNodes, "updatedAt") and df(graphEdges, "updatedAt") instead of
"createdAt" and ensure the code paths that mutate nodes/edges update their
updatedAt timestamps; refer to the df helper and the graphNodes/graphEdges
return entries in this function to locate where to update the field usage and to
verify mutation handlers set updatedAt.

---

Outside diff comments:
In `@src/triggers/api.ts`:
- Around line 1474-1483: The endpoint currently only honors the "since" query
and then computes sinceTime and df (the filter function) but ignores any
syncFilter.project; update request handling to accept/parse the peer's project
filter (e.g., from req.query_params or the incoming syncFilter payload) and
apply it before/alongside the since filter: read the project identifier, compute
sinceTime as now, and modify the df function (or the filtering pipeline that
uses df) to also require that (item as Record<string, unknown>).project ===
syncFilter.project (or matches the provided project param) so returned pulls are
scoped to the requested project as well as the since time. Ensure you reference
the existing symbols since, sinceTime, df and req.query_params when making the
change.

---

Nitpick comments:
In `@test/cascade.test.ts`:
- Around line 12-47: Extract the duplicated mock helpers into a shared module
(e.g., export mockKV and mockSdk from test/helpers/mocks.ts) and replace the
local implementations in cascade.test.ts and verify.test.ts with imports from
that helper; for mockSdk, tighten the loose Function type to a specific
signature like (data: unknown) => Promise<unknown> (use the same signature for
registerFunction's handler and the Map value) so type safety is preserved, and
update the tests to import { mockKV, mockSdk } from the new helper.
- Line 57: The test is using `as never` to silence type errors when calling
registerCascadeFunction(sdk as never, kv as never); replace the unsafe casts by
declaring minimal mock interfaces that include only the methods/properties used
by registerCascadeFunction (e.g., create a local type like MinimalSdk {
methodA(...): ReturnType; ... } and MinimalKv { get(...): Promise<T>; put(...):
Promise<void>; }), type the mock objects (`sdk` and `kv`) with those interfaces,
and then call registerCascadeFunction(sdk as unknown as MinimalSdk, kv as
unknown as MinimalKv) or simply cast to the minimal types directly so TypeScript
enforces the required shape without suppressing checks for unrelated members.

In `@test/mcp-standalone.test.ts`:
- Around line 25-27: The test currently only asserts the tools array length;
update the "getAllTools returns 38 tools" test to also assert that the returned
array from getAllTools contains a tool named "memory_verify" (e.g., use
tools.some(t => t.name === 'memory_verify') or an equivalent name-level
assertion) so the test fails if the specific tool is not exposed even if the
total count matches; modify the test body that calls getAllTools() and add the
additional expect check for the "memory_verify" name.

In `@test/mesh.test.ts`:
- Around line 105-114: Add an integration test (or extend the existing "stores
syncFilter when provided" scenario) that verifies syncFilter actually gates
push/pull: use sdk.trigger("mem::mesh-register") to register two peers (one with
syncFilter: { project: "/my/project" } and one without or with a different
project), create/insert test documents in both projects, invoke the mesh
push/pull/sync operations used elsewhere in tests (the same APIs your suite uses
for pushing and pulling), and assert that the peer with syncFilter only receives
documents matching { project: "/my/project" } while documents from the other
project are not transferred; reference the existing MeshPeer.syncFilter field
and the sdk.trigger registration flow to locate where to add these assertions.

In `@test/verify.test.ts`:
- Around line 33-46: The mockSdk implementation in verify.test.ts is missing the
triggerVoid helper present in cascade.test.ts; update mockSdk (the mockSdk
function and its returned object) to include a triggerVoid method with the same
semantics as cascade.test.ts (accepting id and data, invoking the registered
function without returning a value) or extract mockSdk into a shared test helper
module and import it into both tests so both test files use the same
implementation; reference the mockSdk function and triggerVoid when making the
change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 54789dbb-205f-4d41-a20d-26517d46a8a9

📥 Commits

Reviewing files that changed from the base of the PR and between c248e6b and cfa336f.

📒 Files selected for processing (18)
  • src/functions/cascade.ts
  • src/functions/compress.ts
  • src/functions/consolidate.ts
  • src/functions/export-import.ts
  • src/functions/mesh.ts
  • src/functions/remember.ts
  • src/functions/verify.ts
  • src/index.ts
  • src/mcp/server.ts
  • src/mcp/tools-registry.ts
  • src/triggers/api.ts
  • src/types.ts
  • src/version.ts
  • test/cascade.test.ts
  • test/export-import.test.ts
  • test/mcp-standalone.test.ts
  • test/mesh.test.ts
  • test/verify.test.ts

Comment thread src/functions/cascade.ts
Comment thread src/functions/consolidate.ts Outdated
Comment thread src/functions/mesh.ts Outdated
Comment thread src/functions/mesh.ts
Comment thread src/functions/remember.ts
Comment thread src/triggers/api.ts Outdated
Comment on lines +1481 to +1500
const sinceTime = since ? new Date(since).getTime() : 0;
const df = <T>(items: T[], field: "updatedAt" | "createdAt") =>
items.filter((i) => new Date((i as Record<string, unknown>)[field] as string).getTime() > sinceTime);
const memories = await kv.list<import("../types.js").Memory>(KV.memories);
const actions = await kv.list<import("../types.js").Action>(KV.actions);
const sinceTime = since ? new Date(since).getTime() : 0;
const semantic = await kv.list<import("../types.js").SemanticMemory>(KV.semantic);
const procedural = await kv.list<import("../types.js").ProceduralMemory>(KV.procedural);
const relations = await kv.list<import("../types.js").MemoryRelation>(KV.relations);
const graphNodes = await kv.list<import("../types.js").GraphNode>(KV.graphNodes);
const graphEdges = await kv.list<import("../types.js").GraphEdge>(KV.graphEdges);
return {
status_code: 200,
body: {
memories: memories.filter(
(m) => new Date(m.updatedAt).getTime() > sinceTime,
),
actions: actions.filter(
(a) => new Date(a.updatedAt).getTime() > sinceTime,
),
memories: df(memories, "updatedAt"),
actions: df(actions, "updatedAt"),
semantic: df(semantic, "updatedAt"),
procedural: df(procedural, "updatedAt"),
relations: df(relations, "createdAt"),
graphNodes: df(graphNodes, "createdAt"),
graphEdges: df(graphEdges, "createdAt"),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

REST graph deltas should use last mutation, not createdAt.

graphNodes/graphEdges are filtered by createdAt, but this PR also introduces mutable stale flags and other in-place graph updates. Once a node/edge has been created, later mutations will never satisfy the since filter and will never be pulled.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/triggers/api.ts` around lines 1481 - 1500, The current delta filter
function df(items, field) uses "createdAt" for graphNodes and graphEdges so
later in-place mutations (like stale flags) won't be returned; change the
returned object to call df(graphNodes, "updatedAt") and df(graphEdges,
"updatedAt") instead of "createdAt" and ensure the code paths that mutate
nodes/edges update their updatedAt timestamps; refer to the df helper and the
graphNodes/graphEdges return entries in this function to locate where to update
the field usage and to verify mutation handlers set updatedAt.

…oping

- Filter stale nodes/edges in graph-retrieval.ts and graph.ts traversals
- Use updatedAt (not createdAt) as LWW clock for graph node mesh sync
- Set updatedAt in cascade.ts when flagging nodes as stale
- Skip non-project scopes (semantic, procedural, relations, graph) when
  syncFilter.project is set to prevent leaking unscoped data
- Validate sourceObservationIds as array of strings in remember.ts
- Deduplicate observation IDs in consolidate.ts sourceObservationIds
- mesh-export endpoint uses updatedAt for graph nodes, accepts project param
- Extract shared test helpers into test/helpers/mocks.ts
- Assert memory_verify tool name in mcp-standalone.test.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
src/triggers/api.ts (1)

1503-1506: ⚠️ Potential issue | 🟡 Minor

GraphEdge stale mutations may not propagate in delta exports.

Line 1503-1505 correctly handles GraphNode with updatedAt || createdAt fallback. However, line 1506 uses createdAt for GraphEdge, and the cascade function (lines 44-45 in cascade.ts) sets stale = true on edges without updating any timestamp. This means edges marked stale after creation won't appear in delta exports using the since parameter.

Consider adding updatedAt to GraphEdge in cascade.ts when marking it stale, similar to how it's done for GraphNode at line 33.

🔧 Proposed fix in cascade.ts
         if (overlap) {
           edge.stale = true;
+          edge.updatedAt = now;
           await kv.set(KV.graphEdges, edge.id, edge);
           flaggedEdges++;
         }

Then update the delta filter in api.ts:

-        body.graphEdges = df(graphEdges, "createdAt");
+        body.graphEdges = graphEdges.filter(
+          (e) => new Date(e.updatedAt || e.createdAt).getTime() > sinceTime,
+        );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/triggers/api.ts` around lines 1503 - 1506, Delta exports miss edges
marked stale because cascade.ts sets GraphEdge.stale=true but doesn't update
GraphEdge.updatedAt; update cascade.ts so when you set stale=true on a GraphEdge
(the same place GraphNode.updatedAt is set) also set updatedAt to the current
timestamp, and then change the delta filter in api.ts from df(graphEdges,
"createdAt") to use the updated timestamp (e.g., df(graphEdges, "updatedAt")) so
graphEdges with updatedAt > sinceTime are included; refer to the cascade
function where stale is assigned and to the df() call and body.graphEdges in
api.ts.
src/functions/mesh.ts (1)

308-310: ⚠️ Potential issue | 🟠 Major

Graph edge sync still keyed by createdAt, so post-create updates won’t replicate.

Line 309, Line 386, and Line 430 use createdAt for graph edges. Any later edge mutation (for example stale/metadata updates written with newer timestamps) can be dropped, causing peer divergence.

🔧 Suggested fix
+function graphEdgeTs(edge: GraphEdge): string {
+  return edge.updatedAt || edge.createdAt;
+}
+
+async function lwwMergeGraphEdges(
+  kv: StateKV,
+  items: GraphEdge[] | undefined,
+): Promise<number> {
+  if (!items || !Array.isArray(items)) return 0;
+  let count = 0;
+  for (const item of items) {
+    if (!item.id || typeof item.id !== "string") continue;
+    const ts = graphEdgeTs(item);
+    if (!ts || Number.isNaN(new Date(ts).getTime())) continue;
+    const wrote = await withKeyedLock(`mem:gedge:${item.id}`, async () => {
+      const existing = await kv.get<GraphEdge>(KV.graphEdges, item.id);
+      if (!existing || new Date(ts) > new Date(graphEdgeTs(existing))) {
+        await kv.set(KV.graphEdges, item.id, item);
+        return true;
+      }
+      return false;
+    });
+    if (wrote) count++;
+  }
+  return count;
+}
@@
-accepted += await lwwMergeList(kv, KV.graphEdges, data.graphEdges, "mem:gedge", "createdAt");
+accepted += await lwwMergeGraphEdges(kv, data.graphEdges);
@@
-result.graphEdges = deltaFilter(all, sinceTime, "createdAt");
+result.graphEdges = all.filter((e) => new Date(graphEdgeTs(e)).getTime() > sinceTime);
@@
-applied += await lwwMergeList(kv, KV.graphEdges, data.graphEdges, "mem:gedge", "createdAt");
+applied += await lwwMergeGraphEdges(kv, data.graphEdges);

Also applies to: 384-387, 429-431

🧹 Nitpick comments (3)
src/functions/graph.ts (1)

254-274: Consider adding stale counts to graph stats.

The mem::graph-stats function includes all nodes/edges regardless of stale status, which differs from mem::graph-query that filters them out. This is likely intentional for monitoring, but consider adding staleNodes and staleEdges counts to provide visibility into how many entities have been marked stale.

💡 Optional enhancement
   sdk.registerFunction({ id: "mem::graph-stats" }, async () => {
     const nodes = await kv.list<GraphNode>(KV.graphNodes);
     const edges = await kv.list<GraphEdge>(KV.graphEdges);

     const nodesByType: Record<string, number> = {};
     for (const n of nodes) {
       nodesByType[n.type] = (nodesByType[n.type] || 0) + 1;
     }

     const edgesByType: Record<string, number> = {};
     for (const e of edges) {
       edgesByType[e.type] = (edgesByType[e.type] || 0) + 1;
     }

     return {
       totalNodes: nodes.length,
       totalEdges: edges.length,
+      staleNodes: nodes.filter((n) => n.stale).length,
+      staleEdges: edges.filter((e) => e.stale).length,
       nodesByType,
       edgesByType,
     };
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/functions/graph.ts` around lines 254 - 274, The mem::graph-stats function
currently counts all GraphNode and GraphEdge entries from kv.list without
distinguishing stale items; update the function (the sdk.registerFunction
handler for "mem::graph-stats") to also compute staleNodes and staleEdges by
iterating nodes and edges (the same loops that build nodesByType and
edgesByType) and incrementing counters when item.stale (or the entity's stale
flag) is truthy; return these two new fields alongside totalNodes, totalEdges,
nodesByType and edgesByType so callers can see how many entities are marked
stale.
src/functions/remember.ts (1)

33-35: Validation partially addresses prior concern.

The array check is good, but the past review also suggested validating that each element is a string. The defensive filter at lines 84-86 handles this, but rejecting malformed input early (with a clear error) would be cleaner than silently filtering.

💡 Stricter validation
-      if (data.sourceObservationIds && !Array.isArray(data.sourceObservationIds)) {
-        return { success: false, error: "sourceObservationIds must be an array" };
-      }
+      if (
+        data.sourceObservationIds &&
+        (!Array.isArray(data.sourceObservationIds) ||
+          data.sourceObservationIds.some((id) => typeof id !== "string"))
+      ) {
+        return { success: false, error: "sourceObservationIds must be an array of strings" };
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/functions/remember.ts` around lines 33 - 35, The current validation only
checks that data.sourceObservationIds is an array but lets the later defensive
filter silently drop non-string elements; change the initial validation (the
block that checks data.sourceObservationIds) to also verify every element is a
string (e.g., Array.isArray(data.sourceObservationIds) &&
data.sourceObservationIds.every(id => typeof id === 'string')) and if not return
{ success: false, error: "sourceObservationIds must be an array of strings" } so
malformed input is rejected early instead of being filtered later.
src/functions/mesh.ts (1)

166-167: Defensively copy scope arrays when storing peer config.

Line 166 assigns DEFAULT_SHARED_SCOPES by reference. Cloning here avoids accidental shared in-memory mutation between peer objects/tests.

💡 Small hardening tweak
-sharedScopes: data.sharedScopes || DEFAULT_SHARED_SCOPES,
+sharedScopes: data.sharedScopes ? [...data.sharedScopes] : [...DEFAULT_SHARED_SCOPES],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/functions/mesh.ts` around lines 166 - 167, When assigning sharedScopes in
the peer config (the sharedScopes: data.sharedScopes || DEFAULT_SHARED_SCOPES
line), defensively clone the array instead of storing it by reference; if
data.sharedScopes exists use a shallow copy (e.g. via slice/Array.from/spread)
and if falling back to DEFAULT_SHARED_SCOPES return a cloned copy of that
constant as well, so neither peer objects nor tests can mutate the original
DEFAULT_SHARED_SCOPES in-place.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/functions/cascade.ts`:
- Around line 44-45: When marking edges stale in cascade.ts (the block that sets
edge.stale = true and calls await kv.set(KV.graphEdges, edge.id, edge)), also
set a consistent updatedAt timestamp on the edge (e.g., edge.updatedAt =
Date.now() or the same timestamp used for nodes) before calling kv.set so edges
are updated the same way nodes are when marked stale.

In `@src/functions/mesh.ts`:
- Line 229: The pull/export request is missing the peer.syncFilter.project,
allowing project-scoped peers to pull unscoped data; update the code that calls
the remote export endpoint (the logic that posts to "/agentmemory/mesh/export")
to include peer.syncFilter.project in the request (e.g., append as a query param
or include in the request body), mirroring how collectSyncData uses
peer.syncFilter for push; ensure the same symbol peer.syncFilter.project is
passed when constructing the pull request so project-scoped peers only receive
project-scoped data.

---

Duplicate comments:
In `@src/triggers/api.ts`:
- Around line 1503-1506: Delta exports miss edges marked stale because
cascade.ts sets GraphEdge.stale=true but doesn't update GraphEdge.updatedAt;
update cascade.ts so when you set stale=true on a GraphEdge (the same place
GraphNode.updatedAt is set) also set updatedAt to the current timestamp, and
then change the delta filter in api.ts from df(graphEdges, "createdAt") to use
the updated timestamp (e.g., df(graphEdges, "updatedAt")) so graphEdges with
updatedAt > sinceTime are included; refer to the cascade function where stale is
assigned and to the df() call and body.graphEdges in api.ts.

---

Nitpick comments:
In `@src/functions/graph.ts`:
- Around line 254-274: The mem::graph-stats function currently counts all
GraphNode and GraphEdge entries from kv.list without distinguishing stale items;
update the function (the sdk.registerFunction handler for "mem::graph-stats") to
also compute staleNodes and staleEdges by iterating nodes and edges (the same
loops that build nodesByType and edgesByType) and incrementing counters when
item.stale (or the entity's stale flag) is truthy; return these two new fields
alongside totalNodes, totalEdges, nodesByType and edgesByType so callers can see
how many entities are marked stale.

In `@src/functions/mesh.ts`:
- Around line 166-167: When assigning sharedScopes in the peer config (the
sharedScopes: data.sharedScopes || DEFAULT_SHARED_SCOPES line), defensively
clone the array instead of storing it by reference; if data.sharedScopes exists
use a shallow copy (e.g. via slice/Array.from/spread) and if falling back to
DEFAULT_SHARED_SCOPES return a cloned copy of that constant as well, so neither
peer objects nor tests can mutate the original DEFAULT_SHARED_SCOPES in-place.

In `@src/functions/remember.ts`:
- Around line 33-35: The current validation only checks that
data.sourceObservationIds is an array but lets the later defensive filter
silently drop non-string elements; change the initial validation (the block that
checks data.sourceObservationIds) to also verify every element is a string
(e.g., Array.isArray(data.sourceObservationIds) &&
data.sourceObservationIds.every(id => typeof id === 'string')) and if not return
{ success: false, error: "sourceObservationIds must be an array of strings" } so
malformed input is rejected early instead of being filtered later.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e672e89a-d141-4ca1-94b4-2f665da33997

📥 Commits

Reviewing files that changed from the base of the PR and between cfa336f and d0fbc88.

📒 Files selected for processing (11)
  • src/functions/cascade.ts
  • src/functions/consolidate.ts
  • src/functions/graph-retrieval.ts
  • src/functions/graph.ts
  • src/functions/mesh.ts
  • src/functions/remember.ts
  • src/triggers/api.ts
  • test/cascade.test.ts
  • test/helpers/mocks.ts
  • test/mcp-standalone.test.ts
  • test/verify.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/verify.test.ts

Comment thread src/functions/cascade.ts
Comment on lines +44 to +45
edge.stale = true;
await kv.set(KV.graphEdges, edge.id, edge);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Set updatedAt on edges for consistency with nodes.

Graph nodes get updatedAt set when marked stale (line 33), but edges don't. This creates an inconsistency and affects delta exports.

🔧 Proposed fix
         if (overlap) {
           edge.stale = true;
+          edge.updatedAt = now;
           await kv.set(KV.graphEdges, edge.id, edge);
           flaggedEdges++;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
edge.stale = true;
await kv.set(KV.graphEdges, edge.id, edge);
edge.stale = true;
edge.updatedAt = now;
await kv.set(KV.graphEdges, edge.id, edge);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/functions/cascade.ts` around lines 44 - 45, When marking edges stale in
cascade.ts (the block that sets edge.stale = true and calls await
kv.set(KV.graphEdges, edge.id, edge)), also set a consistent updatedAt timestamp
on the edge (e.g., edge.updatedAt = Date.now() or the same timestamp used for
nodes) before calling kv.set so edges are updated the same way nodes are when
marked stale.

Comment thread src/functions/mesh.ts

if (direction === "push" || direction === "both") {
const pushData = await collectSyncData(kv, scopes, peer.lastSyncAt);
const pushData = await collectSyncData(kv, scopes, peer.lastSyncAt, peer.syncFilter);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

syncFilter.project is applied on push but not on pull export requests.

Line 229 uses peer.syncFilter for outbound collection, but Line 252 does not append project to /agentmemory/mesh/export. A project-scoped peer can still pull unscoped remote data.

🔧 Suggested fix
-const response = await fetch(
-  `${peer.url}/agentmemory/mesh/export?since=${peer.lastSyncAt || ""}`,
-  { signal: AbortSignal.timeout(30000), redirect: "error" },
-);
+const params = new URLSearchParams({ since: peer.lastSyncAt || "" });
+if (peer.syncFilter?.project) {
+  params.set("project", peer.syncFilter.project);
+}
+const response = await fetch(
+  `${peer.url}/agentmemory/mesh/export?${params.toString()}`,
+  { signal: AbortSignal.timeout(30000), redirect: "error" },
+);

Also applies to: 249-254

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/functions/mesh.ts` at line 229, The pull/export request is missing the
peer.syncFilter.project, allowing project-scoped peers to pull unscoped data;
update the code that calls the remote export endpoint (the logic that posts to
"/agentmemory/mesh/export") to include peer.syncFilter.project in the request
(e.g., append as a query param or include in the request body), mirroring how
collectSyncData uses peer.syncFilter for push; ensure the same symbol
peer.syncFilter.project is passed when constructing the pull request so
project-scoped peers only receive project-scoped data.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant