diff --git a/README.md b/README.md index 7c4dea82..a856e529 100644 --- a/README.md +++ b/README.md @@ -666,7 +666,7 @@ Memories decay over time (Ebbinghaus curve). Frequently accessed memories streng | Hook | Captures | |------|----------| -| `SessionStart` | Project path, session ID | +| `SessionStart` | Project path, session ID, model, agent identity | | `UserPromptSubmit` | User prompts (privacy-filtered) | | `PreToolUse` | File access patterns + enriched context | | `PostToolUse` | Tool name, input, output | @@ -1109,6 +1109,32 @@ Full endpoint list: [`src/triggers/api.ts`](src/triggers/api.ts) +`POST /agentmemory/session/start` accepts optional session metadata so teams can +distinguish which client, model, or agent role created a session: + +```json +{ + "sessionId": "ses_123", + "project": "/repo", + "cwd": "/repo", + "model": "claude-sonnet-4-6", + "agent": { + "client": "claude-code", + "model": "claude-sonnet-4-6", + "agentType": "planner", + "sessionSource": "startup" + }, + "metadata": { + "taskType": "refactor", + "agentVersion": "2.1.0" + } +} +``` + +Recall and context results include compact session attribution, so agents can see +which client/model/role produced a retrieved observation instead of receiving an +opaque session ID only. + ---

Development

diff --git a/plugin/scripts/session-start.mjs b/plugin/scripts/session-start.mjs index 9e573e24..effa8c77 100755 --- a/plugin/scripts/session-start.mjs +++ b/plugin/scripts/session-start.mjs @@ -15,6 +15,11 @@ function authHeaders() { if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; return h; } +function nonEmptyString(value) { + if (typeof value !== "string") return void 0; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : void 0; +} async function main() { let input = ""; for await (const chunk of process.stdin) input += chunk; @@ -27,6 +32,15 @@ async function main() { if (isSdkChildContext(data)) return; const sessionId = data.session_id || `ses_${Date.now().toString(36)}`; const project = data.cwd || process.cwd(); + const model = nonEmptyString(data.model); + const agentType = nonEmptyString(data.agent_type); + const sessionSource = nonEmptyString(data.source); + const agent = { + client: "claude-code", + ...model ? { model } : {}, + ...agentType ? { agentType } : {}, + ...sessionSource ? { sessionSource } : {} + }; const url = `${REST_URL}/agentmemory/session/start`; const init = { method: "POST", @@ -34,7 +48,9 @@ async function main() { body: JSON.stringify({ sessionId, project, - cwd: project + cwd: project, + ...model ? { model } : {}, + agent }) }; if (!INJECT_CONTEXT) { diff --git a/src/functions/context.ts b/src/functions/context.ts index 319de1ee..b6df19db 100644 --- a/src/functions/context.ts +++ b/src/functions/context.ts @@ -16,6 +16,7 @@ import { listPinnedSlots, renderPinnedContext, } from "./slots.js"; +import { formatSessionHeading } from "./session-attribution.js"; function estimateTokens(text: string): number { return Math.ceil(text.length / 3); @@ -113,7 +114,7 @@ export function registerContextFunction( for (let i = 0; i < sessions.length; i++) { const summary = summariesPerSession[i]; if (summary) { - const content = `## ${summary.title}\n${summary.narrative}\nDecisions: ${summary.keyDecisions.join("; ")}\nFiles: ${summary.filesModified.join(", ")}`; + const content = `## ${summary.title}\nSource: ${formatSessionHeading(sessions[i])}\n${summary.narrative}\nDecisions: ${summary.keyDecisions.join("; ")}\nFiles: ${summary.filesModified.join(", ")}`; blocks.push({ type: "summary", content, @@ -147,7 +148,7 @@ export function registerContextFunction( const items = top .map((o) => `- [${o.type}] ${o.title}: ${o.narrative}`) .join("\n"); - const content = `## Session ${sessions[i].id.slice(0, 8)} (${sessions[i].startedAt})\n${items}`; + const content = `## ${formatSessionHeading(sessions[i])}\n${items}`; blocks.push({ type: "observation", content, diff --git a/src/functions/search.ts b/src/functions/search.ts index 74af9ff1..ebc220a4 100644 --- a/src/functions/search.ts +++ b/src/functions/search.ts @@ -8,6 +8,7 @@ import type { EmbeddingProvider } from '../types.js' import { memoryToObservation } from '../state/memory-utils.js' import { recordAccessBatch } from './access-tracker.js' import { logger } from "../logger.js"; +import { compactSessionAttribution } from './session-attribution.js' let index: SearchIndex | null = null let vectorIndex: VectorIndex | null = null @@ -216,9 +217,18 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { const sessionCache = new Map() const loadSession = async (sessionId: string): Promise => { if (sessionCache.has(sessionId)) return sessionCache.get(sessionId)! - const s = await kv.get(KV.sessions, sessionId) - sessionCache.set(sessionId, s ?? null) - return s ?? null + try { + const s = await kv.get(KV.sessions, sessionId) + sessionCache.set(sessionId, s ?? null) + return s ?? null + } catch (err) { + logger.warn('search: failed to load session attribution', { + sessionId, + error: err instanceof Error ? err.message : String(err), + }) + sessionCache.set(sessionId, null) + return null + } } // First pass: filter by session (sequential — benefits from session cache). @@ -250,6 +260,9 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { return mem ? memoryToObservation(mem) : null }) ) + const sessionResults = await Promise.all( + candidates.map((r) => loadSession(r.sessionId)), + ) const enriched: SearchResult[] = [] for (let i = 0; i < candidates.length; i++) { const obs = obsResults[i] @@ -258,6 +271,10 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { observation: obs, score: candidates[i].score, sessionId: candidates[i].sessionId, + session: compactSessionAttribution( + candidates[i].sessionId, + sessionResults[i], + ), }) } } @@ -293,6 +310,7 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { const compactResults: CompactSearchResult[] = enriched.map((r) => ({ obsId: r.observation.id, sessionId: r.sessionId, + session: r.session, title: r.observation.title, type: r.observation.type, score: r.score, @@ -312,6 +330,7 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { const narrativeResults = enriched.map((r) => ({ obsId: r.observation.id, sessionId: r.sessionId, + session: r.session, title: r.observation.title, narrative: r.observation.narrative, score: r.score, @@ -319,7 +338,12 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { })) const packed = applyTokenBudget(narrativeResults) const text = packed.items - .map((r, index) => `${index + 1}. ${r.title}\n${r.narrative}`) + .map((r, index) => { + const source = r.session?.label + ? `\nSource: ${r.session.label}` + : `\nSource session: ${r.sessionId}` + return `${index + 1}. ${r.title}${source}\n${r.narrative}` + }) .join('\n\n') return { format, diff --git a/src/functions/session-attribution.ts b/src/functions/session-attribution.ts new file mode 100644 index 00000000..02093497 --- /dev/null +++ b/src/functions/session-attribution.ts @@ -0,0 +1,81 @@ +import type { Session, SessionAttribution } from "../types.js"; + +function asNonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function agentRecord(session: Session | null | undefined): Record { + const agent = session?.agent; + return agent && typeof agent === "object" && !Array.isArray(agent) + ? (agent as Record) + : {}; +} + +export function sessionAgentType(session: Session | null | undefined): string | undefined { + const agent = agentRecord(session); + return ( + asNonEmptyString(agent["agentType"]) || + asNonEmptyString(agent["role"]) || + asNonEmptyString(agent["agent_type"]) + ); +} + +export function sessionSource(session: Session | null | undefined): string | undefined { + const agent = agentRecord(session); + return ( + asNonEmptyString(agent["sessionSource"]) || + asNonEmptyString(agent["source"]) + ); +} + +export function sessionModel(session: Session | null | undefined): string | undefined { + const agent = agentRecord(session); + return asNonEmptyString(agent["model"]) || asNonEmptyString(session?.model); +} + +export function sessionAttributionLabel(session: Session | null | undefined): string { + if (!session) return ""; + const agent = agentRecord(session); + const parts: string[] = []; + const client = asNonEmptyString(agent["client"]); + const role = sessionAgentType(session); + const model = sessionModel(session); + const source = sessionSource(session); + if (client) parts.push(role ? `${client}/${role}` : client); + else if (role) parts.push(role); + if (model) parts.push(model); + if (source) parts.push(`source:${source}`); + if (session.startedAt) parts.push(session.startedAt); + return parts.join(" | "); +} + +export function compactSessionAttribution( + sessionId: string, + session: Session | null | undefined, +): SessionAttribution { + if (!session) return { id: sessionId }; + const model = sessionModel(session); + const role = sessionAgentType(session); + const source = sessionSource(session); + return { + id: session.id, + project: session.project, + startedAt: session.startedAt, + status: session.status, + ...(model ? { model } : {}), + ...(session.agent ? { agent: session.agent } : {}), + ...(role ? { agentType: role } : {}), + ...(source ? { sessionSource: source } : {}), + ...(session.metadata ? { metadata: session.metadata } : {}), + label: sessionAttributionLabel(session), + }; +} + +export function formatSessionHeading(session: Session): string { + const attribution = sessionAttributionLabel(session); + return attribution + ? `Session ${session.id.slice(0, 8)} - ${attribution}` + : `Session ${session.id.slice(0, 8)} (${session.startedAt})`; +} diff --git a/src/functions/session-metadata.ts b/src/functions/session-metadata.ts new file mode 100644 index 00000000..01d8aa56 --- /dev/null +++ b/src/functions/session-metadata.ts @@ -0,0 +1,98 @@ +import type { AgentIdentity, SessionMetadata } from "../types.js"; +import { stripPrivateData } from "./privacy.js"; + +type SessionMetadataResult = { + model?: string; + agent?: AgentIdentity; + metadata?: SessionMetadata; + error?: string; +}; + +function asNonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function asRecord(value: unknown): Record | undefined | null { + if (value === undefined) return undefined; + if (value === null) return null; + if (typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +function redactRecord(record: Record): Record { + try { + return JSON.parse(stripPrivateData(JSON.stringify(record))) as Record< + string, + unknown + >; + } catch { + return {}; + } +} + +export function normalizeSessionMetadata( + body: Record, +): SessionMetadataResult { + const model = asNonEmptyString(body["model"]); + + const rawMetadata = asRecord(body["metadata"]); + if (rawMetadata === null) { + return { error: "metadata must be an object when provided" }; + } + + const rawAgent = asRecord(body["agent"]); + if (rawAgent === null) { + return { error: "agent must be an object when provided" }; + } + + let agent: AgentIdentity | undefined; + + if (rawAgent) { + const client = asNonEmptyString(rawAgent["client"]); + if (!client) { + return { error: "agent.client must be a non-empty string" }; + } + + const agentModel = asNonEmptyString(rawAgent["model"]) || model; + const agentType = + asNonEmptyString(rawAgent["agentType"]) || + asNonEmptyString(rawAgent["role"]) || + asNonEmptyString(rawAgent["agent_type"]); + const sessionSource = + asNonEmptyString(rawAgent["sessionSource"]) || + asNonEmptyString(rawAgent["source"]); + + agent = { + client, + ...(agentModel ? { model: agentModel } : {}), + ...(agentType ? { agentType } : {}), + ...(sessionSource ? { sessionSource } : {}), + }; + } else { + const client = asNonEmptyString(body["agentClient"]); + const agentType = + asNonEmptyString(body["agentType"]) || + asNonEmptyString(body["role"]) || + asNonEmptyString(body["agent_type"]); + const sessionSource = + asNonEmptyString(body["sessionSource"]) || + asNonEmptyString(body["source"]); + + if (client || agentType || sessionSource) { + agent = { + client: client || "unknown", + ...(model ? { model } : {}), + ...(agentType ? { agentType } : {}), + ...(sessionSource ? { sessionSource } : {}), + }; + } + } + + return { + ...(model ? { model } : {}), + ...(agent ? { agent } : {}), + ...(rawMetadata ? { metadata: redactRecord(rawMetadata) } : {}), + }; +} diff --git a/src/functions/smart-search.ts b/src/functions/smart-search.ts index fdeed273..1d267e68 100644 --- a/src/functions/smart-search.ts +++ b/src/functions/smart-search.ts @@ -3,11 +3,13 @@ import type { CompactSearchResult, CompressedObservation, HybridSearchResult, + Session, } from "../types.js"; import { KV } from "../state/schema.js"; import { StateKV } from "../state/kv.js"; import { recordAccessBatch } from "./access-tracker.js"; import { logger } from "../logger.js"; +import { compactSessionAttribution } from "./session-attribution.js"; export function registerSmartSearchFunction( sdk: ISdk, @@ -35,13 +37,21 @@ export function registerSmartSearchFunction( obsId: string; sessionId: string; observation: CompressedObservation; + session?: ReturnType; }> = []; const results = await Promise.all( items.map(({ obsId, sessionId }) => - findObservation(kv, obsId, sessionId).then((obs) => - obs ? { obsId, sessionId: obs.sessionId, observation: obs } : null, - ), + findObservation(kv, obsId, sessionId).then(async (obs) => { + if (!obs) return null; + const session = await loadSession(kv, obs.sessionId); + return { + obsId, + sessionId: obs.sessionId, + observation: obs, + session: compactSessionAttribution(obs.sessionId, session), + }; + }), ), ); for (const r of results) { @@ -70,14 +80,20 @@ export function registerSmartSearchFunction( const limit = Math.max(1, Math.min(data.limit ?? 20, 100)); const hybridResults = await searchFn(data.query, limit); - const compact: CompactSearchResult[] = hybridResults.map((r) => ({ - obsId: r.observation.id, - sessionId: r.sessionId, - title: r.observation.title, - type: r.observation.type, - score: r.combinedScore, - timestamp: r.observation.timestamp, - })); + const compact: CompactSearchResult[] = await Promise.all( + hybridResults.map(async (r) => { + const session = await loadSession(kv, r.sessionId); + return { + obsId: r.observation.id, + sessionId: r.sessionId, + session: compactSessionAttribution(r.sessionId, session), + title: r.observation.title, + type: r.observation.type, + score: r.combinedScore, + timestamp: r.observation.timestamp, + }; + }), + ); void recordAccessBatch( kv, @@ -118,3 +134,10 @@ async function findObservation( } return null; } + +async function loadSession( + kv: StateKV, + sessionId: string, +): Promise { + return kv.get(KV.sessions, sessionId).catch(() => null); +} diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index a6cefe41..4bdee5d7 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -33,6 +33,12 @@ function authHeaders(): Record { return h; } +function nonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + async function main() { let input = ""; for await (const chunk of process.stdin) { @@ -51,12 +57,28 @@ async function main() { const sessionId = (data.session_id as string) || `ses_${Date.now().toString(36)}`; const project = (data.cwd as string) || process.cwd(); + const model = nonEmptyString(data.model); + const agentType = nonEmptyString(data.agent_type); + const sessionSource = nonEmptyString(data.source); + + const agent = { + client: "claude-code", + ...(model ? { model } : {}), + ...(agentType ? { agentType } : {}), + ...(sessionSource ? { sessionSource } : {}), + }; const url = `${REST_URL}/agentmemory/session/start`; const init: RequestInit = { method: "POST", headers: authHeaders(), - body: JSON.stringify({ sessionId, project, cwd: project }), + body: JSON.stringify({ + sessionId, + project, + cwd: project, + ...(model ? { model } : {}), + agent, + }), }; if (!INJECT_CONTEXT) { diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 6a65f233..35715bc7 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -9,6 +9,7 @@ import { VERSION } from "../version.js"; import { timingSafeCompare } from "../auth.js"; import { renderViewerDocument } from "../viewer/document.js"; import { MAX_FILES_UPPER_BOUND } from "../functions/replay.js"; +import { normalizeSessionMetadata } from "../functions/session-metadata.js"; import { isGraphExtractionEnabled, isConsolidationEnabled, @@ -523,6 +524,7 @@ export function registerApiTriggers( const sessionId = asNonEmptyString(body.sessionId); const project = asNonEmptyString(body.project); const cwd = asNonEmptyString(body.cwd); + const metadata = normalizeSessionMetadata(body); if (!sessionId || !project || !cwd) { return { status_code: 400, @@ -531,6 +533,9 @@ export function registerApiTriggers( }, }; } + if (metadata.error) { + return { status_code: 400, body: { error: metadata.error } }; + } const session: Session = { id: sessionId, project, @@ -538,6 +543,9 @@ export function registerApiTriggers( startedAt: new Date().toISOString(), status: "active", observationCount: 0, + ...(metadata.model ? { model: metadata.model } : {}), + ...(metadata.agent ? { agent: metadata.agent } : {}), + ...(metadata.metadata ? { metadata: metadata.metadata } : {}), }; await kv.set(KV.sessions, sessionId, session); const contextResult = await sdk.trigger< diff --git a/src/triggers/events.ts b/src/triggers/events.ts index ce1a282c..40498ce9 100644 --- a/src/triggers/events.ts +++ b/src/triggers/events.ts @@ -5,11 +5,23 @@ import { StateKV } from "../state/kv.js"; import { isReflectEnabled } from "../functions/slots.js"; import { isGraphExtractionEnabled } from "../config.js"; import { logger } from "../logger.js"; +import { normalizeSessionMetadata } from "../functions/session-metadata.js"; export function registerEventTriggers(sdk: ISdk, kv: StateKV): void { sdk.registerFunction( "event::session::started", - async (data: { sessionId: string; project: string; cwd: string }) => { + async (data: { + sessionId: string; + project: string; + cwd: string; + model?: string; + agent?: Session["agent"]; + metadata?: Session["metadata"]; + }) => { + const metadata = normalizeSessionMetadata(data as Record); + if (metadata.error) { + logger.warn("Session metadata ignored", { error: metadata.error }); + } const session: Session = { id: data.sessionId, project: data.project, @@ -17,6 +29,9 @@ export function registerEventTriggers(sdk: ISdk, kv: StateKV): void { startedAt: new Date().toISOString(), status: "active", observationCount: 0, + ...(!metadata.error && metadata.model ? { model: metadata.model } : {}), + ...(!metadata.error && metadata.agent ? { agent: metadata.agent } : {}), + ...(!metadata.error && metadata.metadata ? { metadata: metadata.metadata } : {}), }; await kv.set(KV.sessions, data.sessionId, session); const contextResult = await sdk.trigger< diff --git a/src/types.ts b/src/types.ts index 86a2c971..a444f235 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,11 +7,22 @@ export interface Session { status: "active" | "completed" | "abandoned"; observationCount: number; model?: string; + agent?: AgentIdentity; + metadata?: SessionMetadata; tags?: string[]; firstPrompt?: string; summary?: string; } +export interface AgentIdentity { + client: string; + model?: string; + agentType?: string; + sessionSource?: string; +} + +export type SessionMetadata = Record; + export interface RawObservation { id: string; sessionId: string; @@ -153,6 +164,20 @@ export interface SearchResult { observation: CompressedObservation; score: number; sessionId: string; + session?: SessionAttribution; +} + +export interface SessionAttribution { + id: string; + project?: string; + startedAt?: string; + status?: Session["status"]; + model?: string; + agent?: AgentIdentity; + agentType?: string; + sessionSource?: string; + metadata?: SessionMetadata; + label?: string; } export interface ContextBlock { @@ -240,12 +265,14 @@ export interface HybridSearchResult { graphScore: number; combinedScore: number; sessionId: string; + session?: SessionAttribution; graphContext?: string; } export interface CompactSearchResult { obsId: string; sessionId: string; + session?: SessionAttribution; title: string; type: ObservationType; score: number; diff --git a/src/viewer/index.html b/src/viewer/index.html index 551d016c..6f92ac82 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -502,6 +502,46 @@ color: var(--ink-muted); font-family: var(--font-mono); } + .session-identity { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin: 7px 0 4px; + } + .session-identity .badge { + font-size: 8px; + letter-spacing: 0.06em; + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .session-identity-panel { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 8px; + margin-bottom: 14px; + } + .session-kv { + border: 1px solid var(--border-light); + background: var(--bg-alt); + padding: 8px 10px; + min-width: 0; + } + .session-kv .kv-label { + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-muted); + font-family: var(--font-ui); + margin-bottom: 4px; + } + .session-kv .kv-value { + font-size: 12px; + color: var(--ink); + font-family: var(--font-mono); + overflow-wrap: anywhere; + } .detail-panel { background: var(--bg); @@ -1039,6 +1079,79 @@

agentmemory

if (!s) return ''; return s.length > n ? s.slice(0, n) + '...' : s; } + function isPlainObject(v) { + return v && typeof v === 'object' && !Array.isArray(v); + } + function stringifyMetaValue(v) { + if (v === null || v === undefined) return ''; + if (typeof v === 'string') return v; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + try { return JSON.stringify(v); } catch { return String(v); } + } + function sessionAgent(s) { + return isPlainObject(s && s.agent) ? s.agent : {}; + } + function sessionModel(s) { + var agent = sessionAgent(s); + return agent.model || s.model || ''; + } + function sessionAgentRole(s) { + var agent = sessionAgent(s); + return agent.agentType || agent.role || agent.agent_type || ''; + } + function sessionAgentSource(s) { + var agent = sessionAgent(s); + return agent.sessionSource || agent.source || ''; + } + function sessionAgentLabel(s) { + var agent = sessionAgent(s); + var role = sessionAgentRole(s); + var source = sessionAgentSource(s); + var parts = []; + if (agent.client) parts.push(agent.client); + if (role) parts.push(role); + if (source) parts.push(source); + return parts.join(' / '); + } + function renderSessionIdentityBadges(s) { + var agent = sessionAgent(s); + var model = sessionModel(s); + var role = sessionAgentRole(s); + var source = sessionAgentSource(s); + var html = ''; + if (agent.client) html += '' + esc(agent.client) + ''; + if (role) html += '' + esc(role) + ''; + if (source) html += '' + esc(source) + ''; + if (model) html += '' + esc(model) + ''; + return html ? '
' + html + '
' : ''; + } + function metadataEntries(metadata, limit) { + if (!isPlainObject(metadata)) return []; + return Object.keys(metadata).sort().slice(0, limit || 12).map(function(k) { + return { key: k, value: stringifyMetaValue(metadata[k]) }; + }).filter(function(entry) { return entry.value !== ''; }); + } + function renderSessionIdentityPanel(s) { + var agent = sessionAgent(s); + var model = sessionModel(s); + var role = sessionAgentRole(s); + var source = sessionAgentSource(s); + var entries = []; + if (agent.client) entries.push({ key: 'agent', value: agent.client }); + if (role) entries.push({ key: 'role', value: role }); + if (model) entries.push({ key: 'model', value: model }); + if (source) entries.push({ key: 'source', value: source }); + metadataEntries(s.metadata, 6).forEach(function(entry) { + entries.push({ key: 'meta: ' + entry.key, value: entry.value }); + }); + if (entries.length === 0) return ''; + var html = '
'; + entries.forEach(function(entry) { + html += '
' + esc(entry.key) + '
' + esc(truncate(entry.value, 120)) + '
'; + }); + html += '
'; + return html; + } function debounce(fn, ms) { var t; return function() { @@ -1275,10 +1388,12 @@

agentmemory

html += '

No sessions yet. Start a coding session with agentmemory hooks enabled.

'; } else { var recent = d.sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); }).slice(0, 5); - html += ''; + html += '
ProjectStatusObsStarted
'; recent.forEach(function(s) { var statusBadge = s.status === 'active' ? 'badge-green' : s.status === 'completed' ? 'badge-blue' : 'badge-muted'; + var agentLabel = sessionAgentLabel(s) || sessionModel(s) || '-'; html += ''; + html += ''; html += ''; html += ''; html += ''; @@ -2573,9 +2688,9 @@

agentmemory

if (preview) { html += '
' + esc(truncate(preview, 140)) + '
'; } + html += renderSessionIdentityBadges(s); html += '
' + esc(s.id.slice(0, 12)) + ' · ' + esc(formatTime(s.startedAt)); html += ' · ' + (s.observationCount || 0) + ' obs'; - if (s.model) html += ' · ' + esc(s.model); html += '
'; }); } @@ -2632,6 +2747,8 @@

agentmemory

html += '
' + esc(truncate(preview, 600)) + '
'; } + html += renderSessionIdentityPanel(s); + html += '
'; html += '
OBSERVATIONS
' + obs.length + '
'; html += '
TOOLS USED
' + Object.keys(toolCounts).length + '
'; @@ -2677,6 +2794,10 @@

agentmemory

html += '
started: ' + esc(formatTime(s.startedAt)) + '
'; if (s.endedAt) html += '
ended: ' + esc(formatTime(s.endedAt)) + '
'; if (s.model) html += '
model: ' + esc(s.model) + '
'; + if (s.agent) html += '
agent: ' + esc(JSON.stringify(s.agent)) + '
'; + metadataEntries(s.metadata, 20).forEach(function(entry) { + html += '
metadata.' + esc(entry.key) + ': ' + esc(entry.value) + '
'; + }); if (s.tags && s.tags.length) html += '
tags: ' + s.tags.map(esc).join(', ') + '
'; html += '
'; diff --git a/test/context-injection.test.ts b/test/context-injection.test.ts index d7689638..2bd77efb 100644 --- a/test/context-injection.test.ts +++ b/test/context-injection.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { createServer, type Server } from "node:http"; import { spawn } from "node:child_process"; import { join } from "node:path"; @@ -125,4 +126,61 @@ describe("session-start hook — context injection gate (#143)", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toBe(""); }); + + it("sends Claude Code agent identity when registering a session", async () => { + const received = await new Promise>((resolve, reject) => { + const server: Server = createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + req.on("end", () => { + try { + resolve(JSON.parse(body) as Record); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ context: "" })); + } catch (err) { + reject(err); + } finally { + server.close(); + } + }); + }); + + server.listen(0, "127.0.0.1", async () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("failed to bind test server")); + return; + } + const payload = JSON.stringify({ + session_id: "ses_test", + cwd: "/tmp/fake-project", + source: "startup", + model: "claude-sonnet-4-6", + agent_type: "planner", + }); + const result = await runHook("session-start.mjs", payload, { + AGENTMEMORY_INJECT_CONTEXT: "true", + AGENTMEMORY_URL: `http://127.0.0.1:${address.port}`, + }); + if (result.exitCode !== 0) { + reject(new Error(`hook exited with ${result.exitCode}`)); + } + }); + }); + + expect(received).toMatchObject({ + sessionId: "ses_test", + project: "/tmp/fake-project", + cwd: "/tmp/fake-project", + model: "claude-sonnet-4-6", + agent: { + client: "claude-code", + model: "claude-sonnet-4-6", + agentType: "planner", + sessionSource: "startup", + }, + }); + }); }); diff --git a/test/context-slots.test.ts b/test/context-slots.test.ts index 808597c4..762eeca2 100644 --- a/test/context-slots.test.ts +++ b/test/context-slots.test.ts @@ -175,3 +175,48 @@ describe("mem::context — pinned slot injection", () => { }); }); }); + +describe("mem::context — session attribution", () => { + it("labels recalled session context with agent identity", async () => { + delete process.env["AGENTMEMORY_SLOTS"]; + const kv = mockKV(); + const handler = wireContext(kv); + + await kv.set(KV.sessions, "ses_prev", { + id: "ses_prev", + project: "/tmp/proj", + cwd: "/tmp/proj", + startedAt: "2026-05-12T12:00:00Z", + status: "completed", + observationCount: 1, + model: "claude-sonnet-4-6", + agent: { + client: "claude-code", + model: "claude-sonnet-4-6", + agentType: "planner", + sessionSource: "startup", + }, + }); + await kv.set(KV.observations("ses_prev"), "obs_1", { + id: "obs_1", + sessionId: "ses_prev", + timestamp: "2026-05-12T12:01:00Z", + type: "decision", + title: "Auth decision", + facts: [], + narrative: "Use jose middleware for JWT verification.", + concepts: ["auth"], + files: ["src/auth.ts"], + importance: 8, + }); + + const result = await handler({ + sessionId: "ses_current", + project: "/tmp/proj", + }); + + expect(result.context).toContain("claude-code/planner"); + expect(result.context).toContain("claude-sonnet-4-6"); + expect(result.context).toContain("Auth decision"); + }); +}); diff --git a/test/search.test.ts b/test/search.test.ts index 7bc635ce..e1b23c4a 100644 --- a/test/search.test.ts +++ b/test/search.test.ts @@ -1,63 +1,77 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const mockState = vi.hoisted(() => ({ + functions: new Map unknown>(), + store: new Map>(), + failSessionReads: false, +})); + +vi.mock("iii-sdk", () => ({ + sdk: { + registerFunction: vi.fn( + ( + idOrOpts: string | { id: string }, + handler: (data: unknown) => unknown, + ) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + mockState.functions.set(id, handler); + }, + ), + registerTrigger: vi.fn(), + trigger: vi.fn( + async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = + typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = + typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = mockState.functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + ), + }, + kv: { + get: vi.fn(async (scope: string, key: string): Promise => { + if (mockState.failSessionReads && scope === "mem:sessions") { + throw new Error("session store unavailable"); + } + return (mockState.store.get(scope)?.get(key) as T) ?? null; + }), + set: vi.fn(async (scope: string, key: string, data: T): Promise => { + if (!mockState.store.has(scope)) mockState.store.set(scope, new Map()); + mockState.store.get(scope)!.set(key, data); + return data; + }), + delete: vi.fn(async (scope: string, key: string): Promise => { + mockState.store.get(scope)?.delete(key); + }), + list: vi.fn(async (scope: string): Promise => { + const entries = mockState.store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }), + }, +})); + vi.mock("../src/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); +import { sdk, kv } from "iii-sdk"; import { registerSearchFunction, getSearchIndex, rebuildIndex, setVectorIndex, setEmbeddingProvider, getVectorIndex } from "../src/functions/search.js"; +import { sessionAttributionLabel } from "../src/functions/session-attribution.js"; import { VectorIndex } from "../src/state/vector-index.js"; import { KV } from "../src/state/schema.js"; import type { CompressedObservation, Session } from "../src/types.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 => { - const entries = store.get(scope); - return entries ? (Array.from(entries.values()) as T[]) : []; - }, - }; -} - -function mockSdk() { - const functions = new Map(); - return { - registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { - const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; - functions.set(id, handler); - }, - registerTrigger: () => {}, - trigger: async ( - idOrInput: string | { function_id: string; payload: unknown }, - data?: unknown, - ) => { - const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; - const payload = typeof idOrInput === "string" ? data : idOrInput.payload; - const fn = functions.get(id); - if (!fn) throw new Error(`No function: ${id}`); - return fn(payload); - }, - }; -} - describe("mem::search", () => { - let sdk: ReturnType; - let kv: ReturnType; - beforeEach(async () => { - sdk = mockSdk(); - kv = mockKV(); + mockState.functions.clear(); + mockState.store.clear(); + mockState.failSessionReads = false; + vi.clearAllMocks(); registerSearchFunction(sdk as never, kv as never); const session: Session = { @@ -67,6 +81,16 @@ describe("mem::search", () => { startedAt: "2026-01-01T00:00:00Z", status: "completed", observationCount: 2, + model: "claude-sonnet-4-6", + agent: { + client: "claude-code", + model: "claude-sonnet-4-6", + agentType: "planner", + sessionSource: "startup", + }, + metadata: { + taskType: "auth-review", + }, }; await kv.set(KV.sessions, session.id, session); @@ -106,22 +130,61 @@ describe("mem::search", () => { it("returns full format by default", async () => { const result = (await sdk.trigger("mem::search", { query: "auth middleware", - })) as { format: string; results: Array<{ observation: CompressedObservation }> }; + })) as { format: string; results: Array<{ observation: CompressedObservation; session?: { agent?: { client?: string } } }> }; expect(result.format).toBe("full"); expect(result.results).toHaveLength(1); expect(result.results[0]?.observation.id).toBe("obs_a"); + expect(result.results[0]?.session?.agent?.client).toBe("claude-code"); }); it("returns compact format when requested", async () => { const result = (await sdk.trigger("mem::search", { query: "auth", format: "compact", - })) as { format: string; results: Array<{ obsId: string; title: string }> }; + })) as { format: string; results: Array<{ obsId: string; title: string; session?: { model?: string } }> }; expect(result.format).toBe("compact"); expect(result.results[0]?.obsId).toBe("obs_a"); expect(result.results[0]?.title).toBe("Auth middleware decision"); + expect(result.results[0]?.session?.model).toBe("claude-sonnet-4-6"); + }); + + it("includes session attribution in narrative output", async () => { + const result = (await sdk.trigger("mem::search", { + query: "auth", + format: "narrative", + })) as { text: string }; + + expect(result.text).toContain("Source: claude-code/planner"); + expect(result.text).toContain("claude-sonnet-4-6"); + }); + + it("preserves role-only session attribution", () => { + const label = sessionAttributionLabel({ + id: "ses_role_only", + project: "demo", + startedAt: "2026-01-03T00:00:00Z", + status: "completed", + observationCount: 0, + agent: { role: "reviewer" }, + }); + + expect(label).toContain("reviewer"); + }); + + it("degrades search attribution when session lookup fails", async () => { + mockState.failSessionReads = true; + + const result = (await sdk.trigger("mem::search", { + query: "auth middleware", + })) as { + results: Array<{ observation: CompressedObservation; session?: { id: string; label?: string } }>; + }; + + expect(result.results).toHaveLength(1); + expect(result.results[0]?.observation.id).toBe("obs_a"); + expect(result.results[0]?.session).toEqual({ id: "ses_1" }); }); it("returns narrative text and respects token budget", async () => { diff --git a/test/session-metadata.test.ts b/test/session-metadata.test.ts new file mode 100644 index 00000000..16549d10 --- /dev/null +++ b/test/session-metadata.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { normalizeSessionMetadata } from "../src/functions/session-metadata.js"; + +describe("normalizeSessionMetadata", () => { + it("normalizes explicit agent identity and session metadata", () => { + const result = normalizeSessionMetadata({ + model: "claude-sonnet-4-6", + agent: { + client: "claude-code", + agentType: "planner", + sessionSource: "startup", + }, + metadata: { + taskType: "refactor", + agentVersion: "2.1.0", + }, + }); + + expect(result).toEqual({ + model: "claude-sonnet-4-6", + agent: { + client: "claude-code", + model: "claude-sonnet-4-6", + agentType: "planner", + sessionSource: "startup", + }, + metadata: { + taskType: "refactor", + agentVersion: "2.1.0", + }, + }); + }); + + it("supports top-level agent convenience fields", () => { + const result = normalizeSessionMetadata({ + model: "qwen3-coder", + agentClient: "opencode", + agentType: "worker", + sessionSource: "resume", + }); + + expect(result).toEqual({ + model: "qwen3-coder", + agent: { + client: "opencode", + model: "qwen3-coder", + agentType: "worker", + sessionSource: "resume", + }, + }); + }); + + it("normalizes common agent role and source aliases", () => { + const explicit = normalizeSessionMetadata({ + agent: { + client: "codex", + role: "worker", + source: "handoff-resume", + }, + }); + const topLevel = normalizeSessionMetadata({ + agentClient: "opencode", + agent_type: "local-inference", + source: "manual", + }); + + expect(explicit.agent).toEqual({ + client: "codex", + agentType: "worker", + sessionSource: "handoff-resume", + }); + expect(topLevel.agent).toEqual({ + client: "opencode", + agentType: "local-inference", + sessionSource: "manual", + }); + }); + + it("rejects malformed metadata and agent payloads", () => { + expect(normalizeSessionMetadata({ metadata: "bad" })).toEqual({ + error: "metadata must be an object when provided", + }); + expect(normalizeSessionMetadata({ metadata: null })).toEqual({ + error: "metadata must be an object when provided", + }); + expect(normalizeSessionMetadata({ agent: [] })).toEqual({ + error: "agent must be an object when provided", + }); + expect(normalizeSessionMetadata({ agent: null })).toEqual({ + error: "agent must be an object when provided", + }); + expect(normalizeSessionMetadata({ agent: { model: "missing-client" } })).toEqual({ + error: "agent.client must be a non-empty string", + }); + }); + + it("redacts secrets from custom metadata", () => { + const result = normalizeSessionMetadata({ + metadata: { + note: "safe", + token: "Bearer abcdefghijklmnopqrstuvwxyz1234567890", + }, + }); + + expect(result.metadata).toEqual({ + note: "safe", + token: "[REDACTED_SECRET]", + }); + }); +}); diff --git a/test/smart-search.test.ts b/test/smart-search.test.ts index 4f22d1a9..d8056707 100644 --- a/test/smart-search.test.ts +++ b/test/smart-search.test.ts @@ -105,6 +105,16 @@ describe("Smart Search Function", () => { startedAt: "2026-02-01T00:00:00Z", status: "completed", observationCount: 2, + model: "gpt-5.3-codex", + agent: { + client: "codex", + model: "gpt-5.3-codex", + agentType: "worker", + sessionSource: "handoff-resume", + }, + metadata: { + taskType: "feature-pr", + }, }; await kv.set("mem:sessions", "ses_1", session); await kv.set("mem:obs:ses_1", "obs_1", obs1); @@ -126,17 +136,27 @@ describe("Smart Search Function", () => { expect(result.results[0]).toHaveProperty("type"); expect(result.results[0]).toHaveProperty("score"); expect(result.results[0]).toHaveProperty("timestamp"); + expect(result.results[0]?.session?.agent?.client).toBe("codex"); + expect(result.results[0]?.session?.metadata?.taskType).toBe("feature-pr"); expect(result.results[0]).not.toHaveProperty("narrative"); }); it("expand mode returns full observations for given IDs", async () => { const result = (await sdk.trigger("mem::smart-search", { expandIds: ["obs_1"], - })) as { mode: string; results: Array<{ obsId: string; observation: CompressedObservation }> }; + })) as { + mode: string; + results: Array<{ + obsId: string; + observation: CompressedObservation; + session?: { model?: string }; + }>; + }; expect(result.mode).toBe("expanded"); expect(result.results.length).toBe(1); expect(result.results[0].observation.title).toBe("Auth handler"); + expect(result.results[0].session?.model).toBe("gpt-5.3-codex"); }); it("returns error when query is missing and no expandIds", async () => {
ProjectAgentStatusObsStarted
' + esc(s.project ? s.project.split('/').pop() : s.id.slice(0,8)) + '' + esc(truncate(agentLabel, 38)) + '' + esc(s.status) + '' + (s.observationCount || 0) + '' + esc(shortTime(s.startedAt)) + '