From 0572cbb0a085daa99a6a9c626c457700cfea85fa Mon Sep 17 00:00:00 2001 From: wyh0626 <44987669+wyh0626@users.noreply.github.com> Date: Mon, 11 May 2026 18:42:44 +0800 Subject: [PATCH 1/2] fix(slots): wire pinned slot injection into mem::context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `renderPinnedContext` and `listPinnedSlots` were introduced in #182 and tested in `test/slots.test.ts`, but no code path actually invokes them. `mem::context` (which `/agentmemory/session/start` calls, and which the `session-start.mjs` hook reads back to populate Claude Code's SessionStart context) reads profile / sessions / observations but never reads slots — so pinned content stays in KV and never reaches the conversation. #182's description hedged with "pinned slots **can be** injected into SessionStart context" and described pinned slots as "**candidates for** SessionStart injection". The helpers and tests for them landed; the wiring did not. Reflection has the same shape of problem: `mem::slot-reflect` writes to `pending_items` / `session_patterns` / `project_context` on the Stop hook, but the next session never reads those slots, so the reflect → next-session loop is open. This adds the missing call at the top of `mem::context`, gated by `isSlotsEnabled()` so behaviour for `AGENTMEMORY_SLOTS=false` users is unchanged. Slot content is pushed as a `ContextBlock` with `recency = Date.now()` so pinned content ranks first in the budget-bounded selection — "always current" semantics. The existing `INJECT_CONTEXT` gate in `session-start.mjs` still decides whether the result actually reaches Claude Code stdout, so default-off users are unaffected on both flags. `test/context-slots.test.ts` covers: - pinned global slot lands in returned context - multiple pinned slots render in label-sorted order - unpinned slots are excluded even when they have content - empty pinned slots (the seeded defaults) are excluded - project-scoped slot shadows global slot with the same label - AGENTMEMORY_SLOTS=off path emits nothing Signed-off-by: wyh0626 <44987669+wyh0626@users.noreply.github.com> --- src/functions/context.ts | 18 ++++ test/context-slots.test.ts | 177 +++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 test/context-slots.test.ts diff --git a/src/functions/context.ts b/src/functions/context.ts index c258a45b..afd373eb 100644 --- a/src/functions/context.ts +++ b/src/functions/context.ts @@ -10,6 +10,11 @@ import { KV } from "../state/schema.js"; import { StateKV } from "../state/kv.js"; import { recordAccessBatch } from "./access-tracker.js"; import { logger } from "../logger.js"; +import { + isSlotsEnabled, + listPinnedSlots, + renderPinnedContext, +} from "./slots.js"; function estimateTokens(text: string): number { return Math.ceil(text.length / 3); @@ -33,6 +38,19 @@ export function registerContextFunction( const budget = data.budget || tokenBudget; const blocks: ContextBlock[] = []; + if (isSlotsEnabled()) { + const pinned = await listPinnedSlots(kv).catch(() => []); + const slotContent = renderPinnedContext(pinned); + if (slotContent) { + blocks.push({ + type: "memory", + content: slotContent, + tokens: estimateTokens(slotContent), + recency: Date.now(), + }); + } + } + const profile = await kv .get(KV.profiles, data.project) .catch(() => null); diff --git a/test/context-slots.test.ts b/test/context-slots.test.ts new file mode 100644 index 00000000..808597c4 --- /dev/null +++ b/test/context-slots.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { registerContextFunction } from "../src/functions/context.js"; +import { KV } from "../src/state/schema.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + if (!store.has(scope)) return []; + return Array.from(store.get(scope)!.values()) as T[]; + }, + }; +} + +type ContextHandler = (data: { + sessionId: string; + project: string; + budget?: number; +}) => Promise<{ context: string; blocks: number; tokens: number }>; + +function wireContext(kv: ReturnType) { + let handler: ContextHandler | undefined; + const sdk = { + registerFunction: vi.fn((id: string, cb: ContextHandler) => { + if (id === "mem::context") handler = cb; + }), + } as unknown as import("iii-sdk").ISdk; + registerContextFunction(sdk, kv as never, 2000); + if (!handler) throw new Error("mem::context not registered"); + return handler; +} + +async function seedPinnedSlot( + kv: ReturnType, + label: string, + content: string, + scope: "project" | "global" = "global", +) { + const target = scope === "global" ? KV.globalSlots : KV.slots; + await kv.set(target, label, { + label, + content, + description: "", + sizeLimit: 2000, + pinned: true, + readOnly: false, + scope, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); +} + +describe("mem::context — pinned slot injection", () => { + const ORIGINAL_SLOTS_ENV = process.env["AGENTMEMORY_SLOTS"]; + + afterEach(() => { + if (ORIGINAL_SLOTS_ENV === undefined) { + delete process.env["AGENTMEMORY_SLOTS"]; + } else { + process.env["AGENTMEMORY_SLOTS"] = ORIGINAL_SLOTS_ENV; + } + }); + + describe("when AGENTMEMORY_SLOTS=true", () => { + let kv: ReturnType; + let handler: ContextHandler; + + beforeEach(() => { + process.env["AGENTMEMORY_SLOTS"] = "true"; + kv = mockKV(); + handler = wireContext(kv); + }); + + it("includes pinned global slot content in returned context", async () => { + await seedPinnedSlot(kv, "tool_guidelines", "rule-alpha", "global"); + + const result = await handler({ + sessionId: "ses_a", + project: "/tmp/proj", + }); + + expect(result.context).toContain("tool_guidelines"); + expect(result.context).toContain("rule-alpha"); + expect(result.blocks).toBeGreaterThan(0); + }); + + it("renders multiple pinned slots, sorted by label", async () => { + await seedPinnedSlot(kv, "user_preferences", "pref-alpha", "global"); + await seedPinnedSlot(kv, "tool_guidelines", "rule-alpha", "global"); + + const result = await handler({ + sessionId: "ses_b", + project: "/tmp/proj", + }); + + const guidelinesIdx = result.context.indexOf("tool_guidelines"); + const prefsIdx = result.context.indexOf("user_preferences"); + expect(guidelinesIdx).toBeGreaterThan(-1); + expect(prefsIdx).toBeGreaterThan(-1); + expect(guidelinesIdx).toBeLessThan(prefsIdx); + }); + + it("skips unpinned slots even when they have content", async () => { + await kv.set(KV.globalSlots, "self_notes", { + label: "self_notes", + content: "unpinned-content-alpha", + description: "", + sizeLimit: 1500, + pinned: false, + readOnly: false, + scope: "global", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const result = await handler({ + sessionId: "ses_c", + project: "/tmp/proj", + }); + + expect(result.context).not.toContain("unpinned-content-alpha"); + }); + + it("skips empty pinned slots (the seeded defaults)", async () => { + await seedPinnedSlot(kv, "persona", "", "global"); + + const result = await handler({ + sessionId: "ses_d", + project: "/tmp/proj", + }); + + expect(result.context).not.toContain("persona"); + }); + + it("project-scoped slot shadows global slot with the same label", async () => { + await seedPinnedSlot(kv, "tool_guidelines", "global-value", "global"); + await seedPinnedSlot(kv, "tool_guidelines", "project-value", "project"); + + const result = await handler({ + sessionId: "ses_e", + project: "/tmp/proj", + }); + + expect(result.context).toContain("project-value"); + expect(result.context).not.toContain("global-value"); + }); + }); + + describe("when AGENTMEMORY_SLOTS is off", () => { + it("does not include any slot content", async () => { + delete process.env["AGENTMEMORY_SLOTS"]; + const kv = mockKV(); + const handler = wireContext(kv); + + await seedPinnedSlot(kv, "tool_guidelines", "rule-alpha", "global"); + + const result = await handler({ + sessionId: "ses_f", + project: "/tmp/proj", + }); + + expect(result.context).not.toContain("tool_guidelines"); + expect(result.context).not.toContain("rule-alpha"); + }); + }); +}); From b22e25682eaf05b2a723a9a408f653ec644e2e18 Mon Sep 17 00:00:00 2001 From: wyh0626 <44987669+wyh0626@users.noreply.github.com> Date: Tue, 12 May 2026 00:28:13 +0800 Subject: [PATCH 2/2] fix(slots): parallelize slot and profile reads in mem::context Per CodeRabbit feedback on #288: `listPinnedSlots` and the profile fetch read independent KV scopes, so the project's `src/functions/**` guideline asks for `Promise.all`. When `AGENTMEMORY_SLOTS=false` the slot side short-circuits to `Promise.resolve([])`, so the existing flag gate is preserved and no extra KV traffic is introduced for opt-out users. Signed-off-by: wyh0626 <44987669+wyh0626@users.noreply.github.com> --- src/functions/context.ts | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/functions/context.ts b/src/functions/context.ts index afd373eb..8a25f9b8 100644 --- a/src/functions/context.ts +++ b/src/functions/context.ts @@ -5,6 +5,7 @@ import type { SessionSummary, ContextBlock, ProjectProfile, + MemorySlot, } from "../types.js"; import { KV } from "../state/schema.js"; import { StateKV } from "../state/kv.js"; @@ -38,22 +39,24 @@ export function registerContextFunction( const budget = data.budget || tokenBudget; const blocks: ContextBlock[] = []; - if (isSlotsEnabled()) { - const pinned = await listPinnedSlots(kv).catch(() => []); - const slotContent = renderPinnedContext(pinned); - if (slotContent) { - blocks.push({ - type: "memory", - content: slotContent, - tokens: estimateTokens(slotContent), - recency: Date.now(), - }); - } + const [pinnedSlots, profile] = await Promise.all([ + isSlotsEnabled() + ? listPinnedSlots(kv).catch(() => [] as MemorySlot[]) + : Promise.resolve([] as MemorySlot[]), + kv + .get(KV.profiles, data.project) + .catch(() => null), + ]); + + const slotContent = renderPinnedContext(pinnedSlots); + if (slotContent) { + blocks.push({ + type: "memory", + content: slotContent, + tokens: estimateTokens(slotContent), + recency: Date.now(), + }); } - - const profile = await kv - .get(KV.profiles, data.project) - .catch(() => null); if (profile) { const profileParts = []; if (profile.topConcepts.length > 0) {