@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 +122 -1
- package/dist/policy.js +130 -0
- package/dist/recorder.js +163 -0
- package/dist/server-handle.js +33 -6
- package/package.json +3 -3
- package/spec/episode-index-spec.json +14 -0
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
|
+
}
|
package/dist/recorder.js
ADDED
|
@@ -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
|
+
}
|
package/dist/server-handle.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|