Files
Joel Hooks 41ee1baee4 feat(memory): seed runs-based memory capture — CONTEXT.md, @joelclaw/memory package, and end-to-end spike
Introduces the raw-Run capture architecture (ADR-0243 in the Vault):
- CONTEXT.md at repo root — 13 terms + 21 architectural rules grilled out via the
  domain-model skill (central ingestion, private-by-default Share Grants,
  PDS+App Password identity, NAS-authoritative storage with Typesense as
  rebuildable index, qwen3-embedding:8b @ 768-dim Matryoshka, agent-first API).
- packages/memory/ — types, per-turn chunker (claude-code + pi format detection,
  tool-result role fix), Ollama embedding client with concurrency pool, Typesense
  run_chunks schema, barrel exports. Mirrors @joelclaw/telemetry hexagonal pattern.
- scripts/memory-spike/ — end-to-end validation against real data:
  * ingest.ts ingests a single jsonl into run_chunks_spike
  * bulk-ingest.ts walks ~/.claude/projects/ and ~/.pi/agent/sessions/ with
    sha256 dedup via ~/.joelclaw/memory-spike-ingested.jsonl
  * search.ts supports hybrid / semantic / keyword modes
  * README.md documents observations from the validation run (~708 chunks from
    a 1247-line claude-code session at 1.2 ch/s sequential / 0.9 ch/s observed
    under bulk concurrency; ~420ms end-to-end semantic queries; retrieval
    quality validated on real Runs).

Typecheck clean, biome clean. No production infrastructure affected; spike
writes to isolated run_chunks_spike Typesense collection on the joelclaw
cluster (cleanly deletable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:19:00 -07:00

172 lines
4.7 KiB
TypeScript

#!/usr/bin/env bun
import {
embed,
RUN_CHUNKS_COLLECTION,
} from "../../packages/memory/src/index";
const TYPESENSE_URL = process.env.TYPESENSE_URL ?? "http://localhost:8108";
const TYPESENSE_API_KEY = process.env.TYPESENSE_API_KEY;
if (!TYPESENSE_API_KEY) {
console.error("TYPESENSE_API_KEY not set; aborting.");
process.exit(1);
}
const SPIKE_USER_ID = "joel";
type Mode = "hybrid" | "semantic" | "keyword";
function parseArgs(argv: string[]): { mode: Mode; query: string; limit: number } {
let mode: Mode = "hybrid";
let limit = 10;
const positional: string[] = [];
for (const arg of argv) {
if (arg.startsWith("--mode=")) {
mode = arg.slice("--mode=".length) as Mode;
} else if (arg.startsWith("--limit=")) {
limit = parseInt(arg.slice("--limit=".length), 10) || 10;
} else {
positional.push(arg);
}
}
return { mode, query: positional.join(" "), limit };
}
async function main() {
const { mode, query, limit } = parseArgs(process.argv.slice(2));
if (!query) {
console.error(
'usage: bun scripts/memory-spike/search.ts [--mode=hybrid|semantic|keyword] [--limit=N] "query text"'
);
process.exit(1);
}
console.log(`mode=${mode} limit=${limit} query=${JSON.stringify(query)}`);
console.log("");
const tStart = performance.now();
let searchBody: Record<string, unknown>;
if (mode === "keyword") {
searchBody = {
searches: [
{
collection: RUN_CHUNKS_COLLECTION,
q: query,
query_by: "text",
filter_by: `readable_by:=\`${SPIKE_USER_ID}\``,
per_page: limit,
include_fields: "id,run_id,chunk_idx,role,text,agent_runtime,tags,started_at",
},
],
};
} else {
const tEmbed = performance.now();
const embedding = await embed(query, { dimensions: 768 });
const tAfterEmbed = performance.now();
console.log(
` embedded query in ${(tAfterEmbed - tEmbed).toFixed(0)}ms`
);
const vectorQuery = `embedding:([${embedding.embedding.join(
","
)}], k:${limit * 2})`;
if (mode === "semantic") {
searchBody = {
searches: [
{
collection: RUN_CHUNKS_COLLECTION,
q: "*",
vector_query: vectorQuery,
filter_by: `readable_by:=\`${SPIKE_USER_ID}\``,
per_page: limit,
include_fields: "id,run_id,chunk_idx,role,text,agent_runtime,tags,started_at",
},
],
};
} else {
// hybrid: Typesense auto-combines BM25 on text with vector_query on embedding
searchBody = {
searches: [
{
collection: RUN_CHUNKS_COLLECTION,
q: query,
query_by: "text",
vector_query: vectorQuery,
filter_by: `readable_by:=\`${SPIKE_USER_ID}\``,
per_page: limit,
include_fields: "id,run_id,chunk_idx,role,text,agent_runtime,tags,started_at",
},
],
};
}
}
const searchRes = await fetch(
`${TYPESENSE_URL}/multi_search`,
{
method: "POST",
headers: {
"X-TYPESENSE-API-KEY": TYPESENSE_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify(searchBody),
}
);
if (!searchRes.ok) {
const body = await searchRes.text();
throw new Error(`typesense search failed: ${searchRes.status} ${body}`);
}
const data = (await searchRes.json()) as {
results: Array<{
found: number;
hits: Array<{
document: {
run_id: string;
chunk_idx: number;
role: string;
text: string;
agent_runtime: string;
tags?: string[];
started_at: number;
};
vector_distance?: number;
text_match?: number;
}>;
}>;
};
const tEnd = performance.now();
const result = data.results[0];
console.log(` ${result?.found ?? 0} matches in ${(tEnd - tStart).toFixed(0)}ms`);
console.log("");
if (!result?.hits?.length) {
console.log("(no hits)");
return;
}
result.hits.forEach((hit, i) => {
const { document: d, vector_distance, text_match } = hit;
const score = vector_distance !== undefined
? `vector_dist=${vector_distance.toFixed(4)}`
: text_match !== undefined
? `text_match=${text_match}`
: "";
const when = new Date(d.started_at).toISOString();
const snippet = d.text.slice(0, 220).replace(/\s+/g, " ");
console.log(`${i + 1}. [${d.role}] ${when} ${score}`);
console.log(` run=${d.run_id}:${d.chunk_idx}`);
console.log(` ${snippet}${d.text.length > 220 ? "…" : ""}`);
console.log("");
});
}
main().catch((err) => {
console.error(err);
process.exit(1);
});