Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -1109,6 +1109,32 @@ Full endpoint list: [`src/triggers/api.ts`](src/triggers/api.ts)

</details>

`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.

---

<h2 id="development"><picture><source media="(prefers-color-scheme: dark)" srcset="assets/tags/light/section-development.svg"><img src="assets/tags/section-development.svg" alt="Development" height="32" /></picture></h2>
Expand Down
18 changes: 17 additions & 1 deletion plugin/scripts/session-start.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,14 +32,25 @@ 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",
headers: authHeaders(),
body: JSON.stringify({
sessionId,
project,
cwd: project
cwd: project,
...model ? { model } : {},
agent
})
};
if (!INJECT_CONTEXT) {
Expand Down
5 changes: 3 additions & 2 deletions src/functions/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 28 additions & 4 deletions src/functions/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -216,9 +217,18 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void {
const sessionCache = new Map<string, Session | null>()
const loadSession = async (sessionId: string): Promise<Session | null> => {
if (sessionCache.has(sessionId)) return sessionCache.get(sessionId)!
const s = await kv.get<Session>(KV.sessions, sessionId)
sessionCache.set(sessionId, s ?? null)
return s ?? null
try {
const s = await kv.get<Session>(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).
Expand Down Expand Up @@ -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]
Expand All @@ -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],
),
})
}
}
Expand Down Expand Up @@ -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,
Expand All @@ -312,14 +330,20 @@ 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,
timestamp: r.observation.timestamp,
}))
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,
Expand Down
81 changes: 81 additions & 0 deletions src/functions/session-attribution.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
const agent = session?.agent;
return agent && typeof agent === "object" && !Array.isArray(agent)
? (agent as Record<string, unknown>)
: {};
}

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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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})`;
}
98 changes: 98 additions & 0 deletions src/functions/session-metadata.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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<string, unknown>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function redactRecord(record: Record<string, unknown>): Record<string, unknown> {
try {
return JSON.parse(stripPrivateData(JSON.stringify(record))) as Record<
string,
unknown
>;
} catch {
return {};
}
}

export function normalizeSessionMetadata(
body: Record<string, unknown>,
): 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) } : {}),
};
}
Loading