@topodb/pi 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/extension.js CHANGED
@@ -1,7 +1,14 @@
1
1
  import { Type } from "typebox";
2
- import { TopodbServer } from "./server-handle.js";
2
+ import { TopodbServer, recordingEnabled } from "./server-handle.js";
3
+ import { EpisodeBuffer, extractText, isUsed, buildEpisodeBatch, toRetrievalRecord } from "./recorder.js";
4
+ import { ensurePolicyVersion } from "./policy.js";
3
5
  export default function (pi) {
4
6
  const server = new TopodbServer();
7
+ const recording = recordingEnabled(process.env);
8
+ const buffer = new EpisodeBuffer();
9
+ const memContents = new Map(); // memory id -> content seen at retrieval
10
+ let policyId;
11
+ let policyResolved = false;
5
12
  pi.registerTool({
6
13
  name: "topodb",
7
14
  label: "topodb memory",
@@ -39,6 +46,19 @@ export default function (pi) {
39
46
  };
40
47
  }
41
48
  const result = await server.call(params.tool, params.args ?? {});
49
+ if (recording && buffer.open && (params.tool === "search_memories" || params.tool === "traverse")) {
50
+ try {
51
+ const cap = toRetrievalRecord(params.tool, params.args ?? {}, result);
52
+ if (cap) {
53
+ buffer.addRetrieval(cap.record);
54
+ for (const [id, content] of cap.contents)
55
+ memContents.set(id, content);
56
+ }
57
+ }
58
+ catch (e) {
59
+ console.error(`topodb recorder: retrieval capture failed: ${e.message}`);
60
+ }
61
+ }
42
62
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: { tool: params.tool } };
43
63
  }
44
64
  catch (e) {
@@ -46,7 +66,108 @@ export default function (pi) {
46
66
  }
47
67
  },
48
68
  });
69
+ pi.on("agent_start", async () => {
70
+ if (!recording)
71
+ return;
72
+ try {
73
+ buffer.start(Date.now());
74
+ memContents.clear();
75
+ if (!policyResolved) {
76
+ policyResolved = true;
77
+ const paths = (process.env.TOPODB_POLICY_PATHS ?? "")
78
+ .split(",")
79
+ .map((s) => s.trim())
80
+ .filter(Boolean);
81
+ if (paths.length) {
82
+ policyId = await ensurePolicyVersion((t, a) => server.call(t, a), paths);
83
+ }
84
+ }
85
+ }
86
+ catch (e) {
87
+ console.error(`topodb recorder: agent_start failed: ${e.message}`);
88
+ }
89
+ });
90
+ pi.on("turn_end", async () => {
91
+ if (recording)
92
+ buffer.bumpTurns();
93
+ });
94
+ pi.on("tool_execution_end", async (ev) => {
95
+ if (recording && ev.isError)
96
+ buffer.noteToolError();
97
+ });
98
+ pi.on("agent_end", async (ev) => {
99
+ if (!recording || !buffer.open)
100
+ return;
101
+ try {
102
+ buffer.close();
103
+ const msgs = ev.messages ?? [];
104
+ const firstUser = msgs.find((m) => m.role === "user");
105
+ const goal = extractText(firstUser?.content).slice(0, 2000);
106
+ const assistants = msgs.filter((m) => m.role === "assistant");
107
+ const tokens = assistants.reduce((n, m) => n + (m.usage?.input ?? 0) + (m.usage?.output ?? 0), 0);
108
+ const last = assistants[assistants.length - 1];
109
+ const aborted = last?.stopReason === "aborted" || last?.stopReason === "error" || Boolean(last?.errorMessage);
110
+ const outcome = aborted || buffer.toolErrors > 0 ? "failure" : "success";
111
+ const failure = aborted
112
+ ? `run ended with stopReason=${last?.stopReason ?? "?"}`
113
+ : buffer.toolErrors > 0
114
+ ? `${buffer.toolErrors} tool error(s)`
115
+ : "";
116
+ const textAfter = (atMs) => assistants
117
+ .filter((m) => (m.timestamp ?? 0) >= atMs)
118
+ .map((m) => extractText(m.content))
119
+ .join("\n");
120
+ const used = new Map();
121
+ buffer.retrievals.forEach((r, i) => {
122
+ const hay = textAfter(r.at);
123
+ const s = new Set();
124
+ for (const m of r.returned) {
125
+ const content = memContents.get(m.id) ?? "";
126
+ if (isUsed(content, hay))
127
+ s.add(m.id);
128
+ }
129
+ if (s.size)
130
+ used.set(i, s);
131
+ });
132
+ const cmds = buildEpisodeBatch({
133
+ buffer,
134
+ goal,
135
+ outcome,
136
+ failure,
137
+ endedAt: Date.now(),
138
+ tokens,
139
+ used,
140
+ policyVersionId: policyId,
141
+ });
142
+ await server.call("submit_batch", { commands: cmds });
143
+ }
144
+ catch (e) {
145
+ console.error(`topodb recorder: episode write failed: ${e.message}`);
146
+ }
147
+ });
49
148
  pi.on("session_shutdown", async () => {
149
+ // An episode still open at shutdown (crash/quit mid-run) must be flushed
150
+ // as a failure before the server goes down, so the graph never has a
151
+ // dangling in-progress episode with no outcome recorded.
152
+ if (recording && buffer.open) {
153
+ try {
154
+ buffer.close();
155
+ const cmds = buildEpisodeBatch({
156
+ buffer,
157
+ goal: "",
158
+ outcome: "failure",
159
+ failure: "session shutdown mid-run",
160
+ endedAt: Date.now(),
161
+ tokens: 0,
162
+ used: new Map(),
163
+ policyVersionId: policyId,
164
+ });
165
+ await server.call("submit_batch", { commands: cmds });
166
+ }
167
+ catch {
168
+ /* dying anyway */
169
+ }
170
+ }
50
171
  server.shutdown();
51
172
  });
52
173
  }
package/dist/policy.js ADDED
@@ -0,0 +1,130 @@
1
+ // src/policy.ts — optional PolicyVersion bootstrap: records which prompt/
2
+ // config artifacts were in effect for a run. Hashes the configured artifact
3
+ // files and find-or-creates the content-addressed Artifact nodes plus the
4
+ // PolicyVersion node that INCLUDES exactly that set, so later analysis can
5
+ // tell which policy version produced which episodes.
6
+ import { createHash } from "node:crypto";
7
+ import { readFileSync } from "node:fs";
8
+ const INLINE_LIMIT = 64 * 1024;
9
+ export function hashArtifacts(paths) {
10
+ return [...paths]
11
+ .sort()
12
+ .map((p) => {
13
+ const bytes = readFileSync(p);
14
+ return {
15
+ path: p.replace(/\\/g, "/"),
16
+ sha256: createHash("sha256").update(bytes).digest("hex"),
17
+ content: bytes.length <= INLINE_LIMIT ? bytes.toString("utf8") : "",
18
+ kind: p.endsWith(".md") ? "prompt" : "config",
19
+ };
20
+ });
21
+ }
22
+ /** Find-or-create the PolicyVersion for exactly this artifact set. Returns
23
+ * its node id, or undefined on ANY failure (recording must never break the
24
+ * agent). Wire protocol shapes follow topodb-mcp's JSON results; every
25
+ * access is defensive. */
26
+ export async function ensurePolicyVersion(call, paths) {
27
+ try {
28
+ // Dedupe by sha256 (keep the first path per hash — hashArtifacts sorts
29
+ // by path). Everything downstream — the wanted set, the reuse gate, the
30
+ // create batch, the INCLUDES links — is driven off this deduped list;
31
+ // duplicate content must map to ONE Artifact node and ONE edge, or the
32
+ // exact-match reuse check breaks forever after.
33
+ const bySha = new Map();
34
+ for (const a of hashArtifacts(paths)) {
35
+ if (!bySha.has(a.sha256))
36
+ bySha.set(a.sha256, a);
37
+ }
38
+ const arts = [...bySha.values()];
39
+ if (arts.length === 0)
40
+ return undefined;
41
+ const wanted = new Set(arts.map((a) => a.sha256));
42
+ // 1. Resolve each artifact node by sha256 (find_by_prop).
43
+ const artIds = new Map(); // sha256 -> node id
44
+ for (const a of arts) {
45
+ const res = (await call("find_by_prop", {
46
+ label: "Artifact",
47
+ prop: "sha256",
48
+ value: a.sha256,
49
+ scope: "shared",
50
+ }));
51
+ const id = res?.nodes?.[0]?.id;
52
+ if (id)
53
+ artIds.set(a.sha256, id);
54
+ }
55
+ // 2. If every artifact exists, look for a PolicyVersion whose INCLUDES
56
+ // set matches exactly (traverse inbound from any one artifact).
57
+ if (artIds.size === arts.length) {
58
+ const anyArt = [...artIds.values()][0];
59
+ const res = (await call("traverse", {
60
+ seed_id: anyArt,
61
+ max_hops: 2,
62
+ direction: "both",
63
+ edge_types: ["INCLUDES"],
64
+ scope: "shared",
65
+ }));
66
+ const nodes = res?.subgraph?.nodes ?? [];
67
+ const edges = res?.subgraph?.edges ?? [];
68
+ const shaById = new Map(nodes
69
+ .filter((n) => n.label === "Artifact")
70
+ .map((n) => [n.id, String(n.props?.sha256 ?? "")]));
71
+ for (const v of nodes.filter((n) => n.label === "PolicyVersion")) {
72
+ const included = edges
73
+ .filter((e) => e.from === v.id)
74
+ .map((e) => shaById.get(e.to))
75
+ .filter(Boolean);
76
+ if (included.length === wanted.size &&
77
+ included.every((s) => wanted.has(s))) {
78
+ return v.id; // exact match — reuse, write nothing
79
+ }
80
+ }
81
+ }
82
+ // 3. Create what's missing in ONE batch: absent Artifacts, the new
83
+ // PolicyVersion, INCLUDES edges. v1 intentionally does NOT maintain a
84
+ // Harness node or an ACTIVE_POLICY edge pointing at "the current"
85
+ // version — that would need the previous version's edge closed on
86
+ // every rotation, which is out of scope here. Each episode instead
87
+ // links directly to the PolicyVersion it used (see USED_POLICY in
88
+ // recorder.ts), so "what was active when" is derivable per-episode
89
+ // without a mutable singleton.
90
+ const cmds = [];
91
+ const refOf = new Map(); // sha256 -> "#N" or literal id
92
+ for (const a of arts) {
93
+ const existing = artIds.get(a.sha256);
94
+ if (existing) {
95
+ refOf.set(a.sha256, existing);
96
+ }
97
+ else {
98
+ refOf.set(a.sha256, `#${cmds.length}`);
99
+ cmds.push({
100
+ op: "create_node",
101
+ label: "Artifact",
102
+ scope: "shared",
103
+ props: { path: a.path, kind: a.kind, sha256: a.sha256, content: a.content },
104
+ });
105
+ }
106
+ }
107
+ const verRef = `#${cmds.length}`;
108
+ cmds.push({
109
+ op: "create_node",
110
+ label: "PolicyVersion",
111
+ scope: "shared",
112
+ props: { version: Date.now(), created_at: Date.now(), note: "recorder bootstrap" },
113
+ });
114
+ for (const a of arts) {
115
+ cmds.push({
116
+ op: "link",
117
+ from: verRef,
118
+ to: refOf.get(a.sha256),
119
+ type: "INCLUDES",
120
+ });
121
+ }
122
+ const res = (await call("submit_batch", { commands: cmds }));
123
+ const verIdx = cmds.findIndex((c) => c.label === "PolicyVersion");
124
+ return res?.ids?.[verIdx] ?? undefined;
125
+ }
126
+ catch (e) {
127
+ console.error(`topodb recorder: policy bootstrap failed: ${e.message}`);
128
+ return undefined;
129
+ }
130
+ }
@@ -0,0 +1,163 @@
1
+ // src/recorder.ts — pure episode-recording core: no Pi imports, no I/O.
2
+ // Everything here is deterministic and unit-tested; extension.ts supplies
3
+ // the events and ships the output to topodb-mcp submit_batch.
4
+ /** In-memory state of the currently open episode (one agent run). */
5
+ export class EpisodeBuffer {
6
+ startedAt = 0;
7
+ turns = 0;
8
+ toolErrors = 0;
9
+ retrievals = [];
10
+ isOpen = false;
11
+ start(nowMs) {
12
+ this.startedAt = nowMs;
13
+ this.turns = 0;
14
+ this.toolErrors = 0;
15
+ this.retrievals = [];
16
+ this.isOpen = true;
17
+ }
18
+ addRetrieval(r) {
19
+ if (this.isOpen)
20
+ this.retrievals.push(r);
21
+ }
22
+ bumpTurns() {
23
+ if (this.isOpen)
24
+ this.turns++;
25
+ }
26
+ noteToolError() {
27
+ if (this.isOpen)
28
+ this.toolErrors++;
29
+ }
30
+ close() {
31
+ this.isOpen = false;
32
+ }
33
+ get open() {
34
+ return this.isOpen;
35
+ }
36
+ }
37
+ /** Message content -> plain text: strings pass through, block arrays
38
+ * contribute only their `type === "text"` blocks. Defensive: anything
39
+ * unrecognized contributes nothing. */
40
+ export function extractText(content) {
41
+ if (typeof content === "string")
42
+ return content;
43
+ if (!Array.isArray(content))
44
+ return "";
45
+ return content
46
+ .map((b) => b && typeof b === "object" && b.type === "text"
47
+ ? String(b.text ?? "")
48
+ : "")
49
+ .join("");
50
+ }
51
+ /** Spec tokenization: lowercase alphanumeric runs of length >= 3, deduped,
52
+ * insertion order preserved. */
53
+ export function tokenize(text) {
54
+ const out = new Set();
55
+ for (const m of text.toLowerCase().matchAll(/[a-z0-9]{3,}/g))
56
+ out.add(m[0]);
57
+ return out;
58
+ }
59
+ /** Spec USED rule: >= 50% of the memory's tokens appear in the text. A
60
+ * memory with no tokens is never "used". */
61
+ export function isUsed(memContent, text) {
62
+ const mem = tokenize(memContent);
63
+ if (mem.size === 0)
64
+ return false;
65
+ const hay = tokenize(text);
66
+ let hits = 0;
67
+ for (const t of mem)
68
+ if (hay.has(t))
69
+ hits++;
70
+ return hits / mem.size >= 0.5;
71
+ }
72
+ function collect(node, i, score, out, contents) {
73
+ const id = node?.id;
74
+ if (typeof id !== "string")
75
+ return;
76
+ out.push({ id, rank: i, score });
77
+ const content = node?.props?.content;
78
+ if (typeof content === "string")
79
+ contents.set(id, content);
80
+ }
81
+ /** Map a `search_memories`/`traverse` tool result to a `RetrievalRecord` plus
82
+ * the memory contents it surfaced, or `undefined` when the tool isn't a
83
+ * retrieval call or the result doesn't match the expected wire shape (never
84
+ * throws — recording must never break the agent). Field names follow
85
+ * topodb-mcp's actual JSON (captured from the running server, not guessed):
86
+ * `search_memories` -> `{hits: [{node, score}]}`; `traverse` -> `{subgraph:
87
+ * {nodes, edges}}` where edges use `type` (not `ty`). */
88
+ export function toRetrievalRecord(tool, args, result) {
89
+ const contents = new Map();
90
+ const returned = [];
91
+ if (tool === "search_memories") {
92
+ const hits = result?.hits;
93
+ if (!Array.isArray(hits))
94
+ return undefined;
95
+ hits.forEach((h, i) => {
96
+ const score = typeof h?.score === "number" ? h.score : 0;
97
+ collect(h?.node, i, score, returned, contents);
98
+ });
99
+ const query = typeof args.query === "string" ? args.query : "";
100
+ return { record: { query, at: Date.now(), channel: "text", returned }, contents };
101
+ }
102
+ if (tool === "traverse") {
103
+ const nodes = result?.subgraph?.nodes;
104
+ if (!Array.isArray(nodes))
105
+ return undefined;
106
+ nodes.forEach((n, i) => collect(n, i, 0, returned, contents));
107
+ const query = typeof args.seed_id === "string" ? args.seed_id : "";
108
+ return { record: { query, at: Date.now(), channel: "graph", returned }, contents };
109
+ }
110
+ return undefined;
111
+ }
112
+ /** Assemble the single atomic submit_batch command array for one episode.
113
+ * `used` maps retrieval index -> the set of memory ids judged used. */
114
+ export function buildEpisodeBatch(args) {
115
+ const { buffer } = args;
116
+ const cmds = [
117
+ {
118
+ op: "create_node",
119
+ label: "Episode",
120
+ props: {
121
+ goal: args.goal,
122
+ strategy: "", // a reflection layer fills this later; never guess
123
+ outcome: args.outcome,
124
+ started_at: buffer.startedAt,
125
+ ended_at: args.endedAt,
126
+ turns: buffer.turns,
127
+ tokens: args.tokens,
128
+ confidence: 0.5,
129
+ failure: args.failure,
130
+ },
131
+ },
132
+ ];
133
+ buffer.retrievals.forEach((r, i) => {
134
+ const evRef = `#${cmds.length}`;
135
+ cmds.push({
136
+ op: "create_node",
137
+ label: "RetrievalEvent",
138
+ props: { query: r.query, at: r.at },
139
+ });
140
+ cmds.push({ op: "link", from: "#0", to: evRef, type: "ISSUED" });
141
+ for (const m of r.returned) {
142
+ cmds.push({
143
+ op: "link",
144
+ from: evRef,
145
+ to: m.id,
146
+ type: "RETURNED",
147
+ props: { rank: m.rank, score: m.score, channel: r.channel },
148
+ });
149
+ }
150
+ for (const id of args.used.get(i) ?? []) {
151
+ cmds.push({ op: "link", from: evRef, to: id, type: "USED" });
152
+ }
153
+ });
154
+ if (args.policyVersionId) {
155
+ cmds.push({
156
+ op: "link",
157
+ from: "#0",
158
+ to: args.policyVersionId,
159
+ type: "USED_POLICY",
160
+ });
161
+ }
162
+ return cmds;
163
+ }
@@ -1,7 +1,25 @@
1
1
  // src/server-handle.ts
2
2
  import { createRequire } from "node:module";
3
+ import { mkdirSync } from "node:fs";
4
+ import { dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
3
6
  import { McpStdioClient } from "./mcp-client.js";
4
7
  const require = createRequire(import.meta.url);
8
+ /** Recording defaults ON; `TOPODB_RECORD=0` disables it. */
9
+ export function recordingEnabled(env) {
10
+ return env.TOPODB_RECORD !== "0";
11
+ }
12
+ /** `--spec` launch args pointing at the bundled episode IndexSpec, or `[]`
13
+ * when recording is disabled. `../spec/` is relative to the COMPILED
14
+ * `dist/server-handle.js` — `spec/` sits at the package root alongside
15
+ * `dist/`, so from `dist/` this resolves correctly both in-repo and once
16
+ * published (see package.json `files`). */
17
+ export function episodeSpecArgs(env) {
18
+ if (!recordingEnabled(env))
19
+ return [];
20
+ const specPath = new URL("../spec/episode-index-spec.json", import.meta.url);
21
+ return ["--spec", fileURLToPath(specPath)];
22
+ }
5
23
  export class TopodbServer {
6
24
  env;
7
25
  client;
@@ -12,15 +30,24 @@ export class TopodbServer {
12
30
  static resolveLauncher() {
13
31
  return require.resolve("@topodb/topodb-mcp/bin/topodb-mcp.js");
14
32
  }
15
- args() {
16
- const db = this.env.TOPODB_DB || ".topodb/memory.redb";
17
- const scope = this.env.TOPODB_SCOPE || "shared";
18
- return [TopodbServer.resolveLauncher(), "--db", db, "--scope", scope];
19
- }
20
33
  async ensure() {
21
34
  if (this.client?.running)
22
35
  return this.client;
23
- this.client = new McpStdioClient(this.args());
36
+ const db = this.env.TOPODB_DB || ".topodb/memory.redb";
37
+ const scope = this.env.TOPODB_SCOPE || "shared";
38
+ // topodb-mcp creates the db file on open but treats a missing parent
39
+ // directory as a startup error — and the default `.topodb/` won't exist in
40
+ // a fresh project. Create it so the server comes up on first use.
41
+ mkdirSync(dirname(db), { recursive: true });
42
+ const args = [
43
+ TopodbServer.resolveLauncher(),
44
+ "--db",
45
+ db,
46
+ "--scope",
47
+ scope,
48
+ ...episodeSpecArgs(this.env),
49
+ ];
50
+ this.client = new McpStdioClient(args);
24
51
  await this.client.start();
25
52
  return this.client;
26
53
  }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@topodb/pi",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Pi (pi.dev) extension: topodb agent memory via one install.",
5
5
  "type": "module",
6
6
  "license": "MIT OR Apache-2.0",
7
7
  "repository": { "type": "git", "url": "https://github.com/TopoDB/TopoDB" },
8
8
  "keywords": ["pi-package", "pi", "topodb", "memory", "mcp"],
9
9
  "pi": { "extensions": ["./dist/extension.js"] },
10
- "files": ["dist/", "README.md"],
10
+ "files": ["dist/", "spec/", "README.md"],
11
11
  "engines": { "node": ">=22.19.0" },
12
12
  "scripts": {
13
13
  "build": "tsc",
@@ -15,7 +15,7 @@
15
15
  "prepublishOnly": "npm run build"
16
16
  },
17
17
  "dependencies": {
18
- "@topodb/topodb-mcp": "0.0.3",
18
+ "@topodb/topodb-mcp": "0.0.5",
19
19
  "typebox": "1.1.38"
20
20
  },
21
21
  "devDependencies": {
@@ -0,0 +1,14 @@
1
+ {
2
+ "equality": [
3
+ { "label": "Entity", "prop": "name" },
4
+ { "label": "Artifact", "prop": "sha256" },
5
+ { "label": "PolicyVersion", "prop": "version" },
6
+ { "label": "Procedure", "prop": "name" },
7
+ { "label": "Episode", "prop": "outcome" }
8
+ ],
9
+ "text": [
10
+ { "label": "Memory", "prop": "content" },
11
+ { "label": "Lesson", "prop": "content" },
12
+ { "label": "Episode", "prop": "goal" }
13
+ ]
14
+ }