@foldrun/core 0.2.0
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/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- package/templates/hello/tools/wordcount/tool.md +22 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// Two tools over what an agent already has and cannot easily reach at scale:
|
|
2
|
+
// its documents, and its own past.
|
|
3
|
+
//
|
|
4
|
+
// tools: [search] search_files — ranked full-text search over knowledge,
|
|
5
|
+
// memory, state and storage, all three scopes
|
|
6
|
+
// tools: [history] recall_runs / read_run — what this workspace's earlier
|
|
7
|
+
// runs concluded, and what their steps said
|
|
8
|
+
// tools: [desks] recall_desk_runs / read_desk_run — the same, for every
|
|
9
|
+
// OTHER workspace in the account
|
|
10
|
+
//
|
|
11
|
+
// Both take values, not stores: a list of directories, a list of run
|
|
12
|
+
// digests. That is what lets the same servers be rebuilt inside a run
|
|
13
|
+
// container, where the vault, the run journal and the account do not exist
|
|
14
|
+
// — the host gathers, the boundary is crossed as JSON, the container serves.
|
|
15
|
+
//
|
|
16
|
+
// Search is lexical (BM25 over tokens), not embeddings. It runs anywhere,
|
|
17
|
+
// needs no vendor, and nothing leaves the box — which is the property the
|
|
18
|
+
// search-on-our-box work already paid for. Behind the tool is an inverted
|
|
19
|
+
// index built once per step and refreshed by mtime: a call tokenises only
|
|
20
|
+
// the files that changed since the last one (the agent writing to state/
|
|
21
|
+
// mid-step is the common case) and scores only the postings of the query's
|
|
22
|
+
// terms, so the tenth search over ten thousand files costs what the first
|
|
23
|
+
// one over ten did. Nothing is persisted: a step's index dies with the
|
|
24
|
+
// step, so it can never be stale across a deploy or a hand edit.
|
|
25
|
+
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { z } from "zod";
|
|
29
|
+
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
30
|
+
import type { RunRecord } from "./store.ts";
|
|
31
|
+
|
|
32
|
+
// ------------------------------------------------------------------ search
|
|
33
|
+
|
|
34
|
+
export interface SearchRoot {
|
|
35
|
+
/** How the agent should name the place: "knowledge/", "../../memory/". */
|
|
36
|
+
label: string;
|
|
37
|
+
/** The directory, absolute. */
|
|
38
|
+
dir: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const TEXT_EXT = new Set([".md", ".txt", ".csv", ".json", ".yaml", ".yml", ".html", ".tsv"]);
|
|
42
|
+
const MAX_FILE_BYTES = 512_000;
|
|
43
|
+
const MAX_FILES = 5_000;
|
|
44
|
+
|
|
45
|
+
interface Doc {
|
|
46
|
+
label: string;
|
|
47
|
+
rel: string;
|
|
48
|
+
text: string;
|
|
49
|
+
/** Frontmatter name/title/description, weighted higher than the body. */
|
|
50
|
+
head: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A file as the index holds it: tokenised once, refreshed when its mtime moves. */
|
|
54
|
+
interface IndexedDoc extends Doc {
|
|
55
|
+
abs: string;
|
|
56
|
+
size: number;
|
|
57
|
+
mtimeMs: number;
|
|
58
|
+
tf: Map<string, number>;
|
|
59
|
+
length: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function docFrom(root: SearchRoot, abs: string, st: fs.Stats): IndexedDoc | null {
|
|
63
|
+
try {
|
|
64
|
+
const text = fs.readFileSync(abs, "utf8");
|
|
65
|
+
const front = text.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? "";
|
|
66
|
+
const head = [...front.matchAll(/^(?:name|title|description):\s*(.+)$/gm)].map((m) => m[1]).join(" ");
|
|
67
|
+
const body = tokens(head + " " + head + " " + head + " " + text);
|
|
68
|
+
const tf = new Map<string, number>();
|
|
69
|
+
for (const t of body) tf.set(t, (tf.get(t) ?? 0) + 1);
|
|
70
|
+
return { label: root.label, rel: path.relative(root.dir, abs).replaceAll("\\", "/"), text, head, abs, size: st.size, mtimeMs: st.mtimeMs, tf, length: body.length };
|
|
71
|
+
} catch {
|
|
72
|
+
return null; // unreadable: not a hit
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Every indexable file under the roots, with the stat the index compares against. */
|
|
77
|
+
function listFiles(roots: SearchRoot[]): { root: SearchRoot; abs: string; st: fs.Stats }[] {
|
|
78
|
+
const out: { root: SearchRoot; abs: string; st: fs.Stats }[] = [];
|
|
79
|
+
for (const root of roots) {
|
|
80
|
+
if (!fs.existsSync(root.dir)) continue;
|
|
81
|
+
const walk = (dir: string) => {
|
|
82
|
+
let entries: fs.Dirent[];
|
|
83
|
+
try {
|
|
84
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
85
|
+
} catch {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
for (const e of entries) {
|
|
89
|
+
if (out.length >= MAX_FILES) return;
|
|
90
|
+
const abs = path.join(dir, e.name);
|
|
91
|
+
if (e.isDirectory()) {
|
|
92
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
93
|
+
walk(abs);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!e.isFile() || !TEXT_EXT.has(path.extname(e.name).toLowerCase())) continue;
|
|
97
|
+
// OKF's generated files describe the bundle, not a fact in it.
|
|
98
|
+
if (e.name === "index.md" || e.name === "log.md") continue;
|
|
99
|
+
try {
|
|
100
|
+
const st = fs.statSync(abs);
|
|
101
|
+
if (st.size > MAX_FILE_BYTES) continue;
|
|
102
|
+
out.push({ root, abs, st });
|
|
103
|
+
} catch {
|
|
104
|
+
// vanished between readdir and stat
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
walk(root.dir);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The inverted index over a set of roots, scored BM25's way — term
|
|
115
|
+
* frequency saturating, rare terms weighing more, long documents not
|
|
116
|
+
* winning by bulk — with the frontmatter head counted three times, because
|
|
117
|
+
* a memory file's name and description are what its author thought it was
|
|
118
|
+
* about. `refresh()` walks the tree (a
|
|
119
|
+
* stat per file — cheap), re-reads only files whose size or mtime moved,
|
|
120
|
+
* drops the ones that went, and rebuilds the postings from the per-file
|
|
121
|
+
* token maps it already holds. `search()` touches only the query terms'
|
|
122
|
+
* postings.
|
|
123
|
+
*/
|
|
124
|
+
export class SearchIndex {
|
|
125
|
+
private docs = new Map<string, IndexedDoc>();
|
|
126
|
+
private postings = new Map<string, { doc: IndexedDoc; tf: number }[]>();
|
|
127
|
+
private avg = 1;
|
|
128
|
+
/** How many files the last refresh read from disk — what a test watches. */
|
|
129
|
+
lastRead = 0;
|
|
130
|
+
|
|
131
|
+
private roots: SearchRoot[];
|
|
132
|
+
|
|
133
|
+
constructor(roots: SearchRoot[]) {
|
|
134
|
+
this.roots = roots;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
refresh(): this {
|
|
138
|
+
const seen = new Set<string>();
|
|
139
|
+
let changed = false;
|
|
140
|
+
this.lastRead = 0;
|
|
141
|
+
for (const { root, abs, st } of listFiles(this.roots)) {
|
|
142
|
+
seen.add(abs);
|
|
143
|
+
const have = this.docs.get(abs);
|
|
144
|
+
if (have && have.size === st.size && have.mtimeMs === st.mtimeMs) continue;
|
|
145
|
+
const doc = docFrom(root, abs, st);
|
|
146
|
+
this.lastRead++;
|
|
147
|
+
if (doc) this.docs.set(abs, doc);
|
|
148
|
+
else this.docs.delete(abs);
|
|
149
|
+
changed = true;
|
|
150
|
+
}
|
|
151
|
+
for (const abs of [...this.docs.keys()]) {
|
|
152
|
+
if (!seen.has(abs)) {
|
|
153
|
+
this.docs.delete(abs);
|
|
154
|
+
changed = true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (changed) this.rebuild();
|
|
158
|
+
return this;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private rebuild(): void {
|
|
162
|
+
this.postings = new Map();
|
|
163
|
+
let total = 0;
|
|
164
|
+
for (const doc of this.docs.values()) {
|
|
165
|
+
total += doc.length;
|
|
166
|
+
for (const [term, tf] of doc.tf) {
|
|
167
|
+
let list = this.postings.get(term);
|
|
168
|
+
if (!list) this.postings.set(term, (list = []));
|
|
169
|
+
list.push({ doc, tf });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
this.avg = total / this.docs.size || 1;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
get size(): number {
|
|
176
|
+
return this.docs.size;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
search(query: string, limit = 10): SearchHit[] {
|
|
180
|
+
const q = [...new Set(tokens(query))];
|
|
181
|
+
if (q.length === 0 || this.docs.size === 0) return [];
|
|
182
|
+
const n = this.docs.size;
|
|
183
|
+
const k1 = 1.4;
|
|
184
|
+
const bParam = 0.75;
|
|
185
|
+
const scores = new Map<IndexedDoc, number>();
|
|
186
|
+
for (const term of q) {
|
|
187
|
+
const list = this.postings.get(term);
|
|
188
|
+
if (!list) continue;
|
|
189
|
+
const idf = Math.log(1 + (n - list.length + 0.5) / (list.length + 0.5));
|
|
190
|
+
for (const { doc, tf } of list) {
|
|
191
|
+
const part = idf * ((tf * (k1 + 1)) / (tf + k1 * (1 - bParam + bParam * (doc.length / this.avg))));
|
|
192
|
+
scores.set(doc, (scores.get(doc) ?? 0) + part);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return [...scores.entries()]
|
|
196
|
+
.sort((a, b) => b[1] - a[1])
|
|
197
|
+
.slice(0, Math.min(Math.max(1, limit), 30))
|
|
198
|
+
.map(([d, score]) => ({ path: `${d.label}${d.rel}`, score: Math.round(score * 100) / 100, snippet: snippetFor(d.text, q) }));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function tokens(text: string): string[] {
|
|
203
|
+
return text
|
|
204
|
+
.toLowerCase()
|
|
205
|
+
.split(/[^a-z0-9$@._-]+/)
|
|
206
|
+
.map((t) => t.replace(/^[._-]+|[._-]+$/g, ""))
|
|
207
|
+
.filter((t) => t.length > 1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface SearchHit {
|
|
211
|
+
path: string;
|
|
212
|
+
score: number;
|
|
213
|
+
snippet: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The line that mentions the most query terms, trimmed — enough to tell
|
|
217
|
+
* whether to open the file, not a substitute for opening it. */
|
|
218
|
+
function snippetFor(text: string, q: string[]): string {
|
|
219
|
+
let best = "";
|
|
220
|
+
let bestN = 0;
|
|
221
|
+
for (const line of text.split("\n")) {
|
|
222
|
+
const l = line.toLowerCase();
|
|
223
|
+
const n = q.filter((t) => l.includes(t)).length;
|
|
224
|
+
if (n > bestN && line.trim()) {
|
|
225
|
+
bestN = n;
|
|
226
|
+
best = line.trim();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return best.length > 200 ? `${best.slice(0, 197)}…` : best;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function searchRoots(roots: SearchRoot[], query: string, limit = 10): SearchHit[] {
|
|
233
|
+
return new SearchIndex(roots).refresh().search(query, limit);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface ContextToolResult {
|
|
237
|
+
server: ReturnType<typeof createSdkMcpServer> | null;
|
|
238
|
+
toolNames: string[];
|
|
239
|
+
promptLines: string[];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function buildSearchTools(roots: SearchRoot[]): ContextToolResult {
|
|
243
|
+
if (roots.length === 0) return { server: null, toolNames: [], promptLines: [] };
|
|
244
|
+
// One index per server — per step. Built on the first call, not at
|
|
245
|
+
// start-up, so a step that never searches never pays for it.
|
|
246
|
+
const index = new SearchIndex(roots);
|
|
247
|
+
const search = tool(
|
|
248
|
+
"search_files",
|
|
249
|
+
`Full-text search over this agent's knowledge, memory, state and stored files (all scopes: own, workspace, account). ` +
|
|
250
|
+
`Returns the best-matching paths with a one-line snippet each — open a hit with Read to use it. ` +
|
|
251
|
+
`Use it instead of guessing which of many files holds a fact.`,
|
|
252
|
+
{
|
|
253
|
+
query: z.string().describe("Words to look for — names, ids, phrases"),
|
|
254
|
+
limit: z.number().int().min(1).max(30).optional().describe("How many hits (default 10)"),
|
|
255
|
+
},
|
|
256
|
+
async (args) => {
|
|
257
|
+
const hits = index.refresh().search(args.query, args.limit ?? 10);
|
|
258
|
+
const text = hits.length
|
|
259
|
+
? hits.map((h) => `${h.path} (${h.score})\n ${h.snippet}`).join("\n")
|
|
260
|
+
: "no files mention that";
|
|
261
|
+
return { content: [{ type: "text" as const, text }] };
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
return {
|
|
265
|
+
server: createSdkMcpServer({ name: "foldrun_search", version: "1.0.0", tools: [search] }),
|
|
266
|
+
toolNames: ["mcp__foldrun_search__search_files"],
|
|
267
|
+
promptLines: [
|
|
268
|
+
`- **search_files(query)** — ranked search across ${roots.map((r) => r.label).join(", ")}. Search first, then Read the hit.`,
|
|
269
|
+
],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ----------------------------------------------------------------- history
|
|
274
|
+
|
|
275
|
+
export interface RunDigest {
|
|
276
|
+
/** Which workspace it ran in. Absent for this workspace's own history,
|
|
277
|
+
* where naming it every line would be noise. */
|
|
278
|
+
workspace?: string;
|
|
279
|
+
id: string;
|
|
280
|
+
flow: string;
|
|
281
|
+
status: string;
|
|
282
|
+
startedAt: string;
|
|
283
|
+
finishedAt: string | null;
|
|
284
|
+
summary: string | null;
|
|
285
|
+
costUsd: number;
|
|
286
|
+
tags: string[];
|
|
287
|
+
steps: { agent: string; status: string; result: string | null }[];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const STEP_RESULT_CHARS = 6_000;
|
|
291
|
+
|
|
292
|
+
/** The digest of a workspace's recent runs — host-side, from the records. */
|
|
293
|
+
export function digestRuns(runs: RunRecord[], limit = 30, exceptRunId?: string): RunDigest[] {
|
|
294
|
+
return runs
|
|
295
|
+
.filter((r) => r.id !== exceptRunId && (r.status === "completed" || r.status === "failed"))
|
|
296
|
+
.sort((a, b) => (b.startedAt > a.startedAt ? 1 : -1))
|
|
297
|
+
.slice(0, limit)
|
|
298
|
+
.map((r) => ({
|
|
299
|
+
id: r.id,
|
|
300
|
+
flow: r.flow,
|
|
301
|
+
status: r.status,
|
|
302
|
+
startedAt: r.startedAt,
|
|
303
|
+
finishedAt: r.finishedAt,
|
|
304
|
+
summary: r.summary ?? null,
|
|
305
|
+
costUsd: r.steps.reduce((n, s) => n + (s.costUsd ?? 0), 0),
|
|
306
|
+
tags: r.tags ?? [],
|
|
307
|
+
steps: r.steps
|
|
308
|
+
.filter((s) => s.status === "completed" || s.status === "failed")
|
|
309
|
+
.map((s) => ({
|
|
310
|
+
agent: s.agent,
|
|
311
|
+
status: s.status,
|
|
312
|
+
result: s.result ? (s.result.length > STEP_RESULT_CHARS ? `${s.result.slice(0, STEP_RESULT_CHARS)}…` : s.result) : null,
|
|
313
|
+
})),
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function buildHistoryTools(digest: RunDigest[]): ContextToolResult {
|
|
318
|
+
const recall = tool(
|
|
319
|
+
"recall_runs",
|
|
320
|
+
`What this workspace's earlier runs concluded — newest first, one line each (id, flow, when, status, cost, summary). ` +
|
|
321
|
+
`Filter by flow, status or a word. Use it to avoid redoing yesterday's work, to continue where a run left off, ` +
|
|
322
|
+
`or to see what a scheduled flow found last time. Then read_run(id) for the detail.`,
|
|
323
|
+
{
|
|
324
|
+
limit: z.number().int().min(1).max(50).optional().describe("How many (default 10)"),
|
|
325
|
+
flow: z.string().optional().describe("Only runs of this flow"),
|
|
326
|
+
status: z.enum(["completed", "failed"]).optional(),
|
|
327
|
+
query: z.string().optional().describe("Only runs whose summary or results mention this"),
|
|
328
|
+
},
|
|
329
|
+
async (args) => {
|
|
330
|
+
const q = (args.query ?? "").toLowerCase();
|
|
331
|
+
const rows = digest
|
|
332
|
+
.filter((r) => !args.flow || r.flow === args.flow)
|
|
333
|
+
.filter((r) => !args.status || r.status === args.status)
|
|
334
|
+
.filter(
|
|
335
|
+
(r) =>
|
|
336
|
+
!q ||
|
|
337
|
+
(r.summary ?? "").toLowerCase().includes(q) ||
|
|
338
|
+
r.steps.some((s) => (s.result ?? "").toLowerCase().includes(q)),
|
|
339
|
+
)
|
|
340
|
+
.slice(0, args.limit ?? 10)
|
|
341
|
+
.map((r) => `${r.id} ${r.flow} ${r.startedAt.slice(0, 16)} ${r.status} $${r.costUsd.toFixed(3)} ${r.summary ?? "(no summary)"}`);
|
|
342
|
+
return { content: [{ type: "text" as const, text: rows.length ? rows.join("\n") : "no earlier runs match" }] };
|
|
343
|
+
},
|
|
344
|
+
);
|
|
345
|
+
const read = tool(
|
|
346
|
+
"read_run",
|
|
347
|
+
"One earlier run in full: each step's agent, status and what it replied (long replies trimmed).",
|
|
348
|
+
{ id: z.string().describe("A run id from recall_runs") },
|
|
349
|
+
async (args) => {
|
|
350
|
+
const r = digest.find((d) => d.id === args.id);
|
|
351
|
+
if (!r) return { content: [{ type: "text" as const, text: `no run ${args.id} in the recent history` }], isError: true };
|
|
352
|
+
const text =
|
|
353
|
+
`${r.flow} · ${r.status} · started ${r.startedAt}${r.finishedAt ? ` · finished ${r.finishedAt}` : ""} · $${r.costUsd.toFixed(4)}\n` +
|
|
354
|
+
(r.summary ? `summary: ${r.summary}\n` : "") +
|
|
355
|
+
r.steps.map((s) => `\n## ${s.agent} (${s.status})\n${s.result ?? "(no reply)"}`).join("\n");
|
|
356
|
+
return { content: [{ type: "text" as const, text }] };
|
|
357
|
+
},
|
|
358
|
+
);
|
|
359
|
+
return {
|
|
360
|
+
server: createSdkMcpServer({ name: "foldrun_history", version: "1.0.0", tools: [recall, read] }),
|
|
361
|
+
toolNames: ["mcp__foldrun_history__recall_runs", "mcp__foldrun_history__read_run"],
|
|
362
|
+
promptLines: [
|
|
363
|
+
`- **recall_runs()** / **read_run(id)** — this workspace's last ${digest.length} finished runs and what they concluded. ` +
|
|
364
|
+
`Check before repeating work a recent run already did.`,
|
|
365
|
+
],
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The same two verbs across the account's OTHER workspaces.
|
|
371
|
+
*
|
|
372
|
+
* A digest agent has to read what six desks concluded this week, and it
|
|
373
|
+
* cannot: a run pod's network policy denies the platform's own API, and the
|
|
374
|
+
* account library is mounted read-only, so there is no path from inside a
|
|
375
|
+
* sandbox to another workspace's files. This is that path, built the way
|
|
376
|
+
* `history` already is — the host gathers the records, the values cross the
|
|
377
|
+
* boundary as JSON, and the container serves them. Nothing new is reachable
|
|
378
|
+
* from a run; what crosses is what the host decided to send.
|
|
379
|
+
*
|
|
380
|
+
* Read-only and summary-shaped on purpose: run records, not files. An agent
|
|
381
|
+
* that needs another desk's *file* is asking for something this platform
|
|
382
|
+
* deliberately does not have.
|
|
383
|
+
*/
|
|
384
|
+
export function buildDeskTools(digest: RunDigest[]): ContextToolResult {
|
|
385
|
+
const workspaces = [...new Set(digest.map((d) => d.workspace ?? "?"))].sort();
|
|
386
|
+
const recall = tool(
|
|
387
|
+
"recall_desk_runs",
|
|
388
|
+
`What the account's OTHER workspaces' runs concluded — newest first, one line each ` +
|
|
389
|
+
`(workspace, id, flow, when, status, cost, summary). The summary is the run's headline: ` +
|
|
390
|
+
`since every desk's last step opens with its verdict, this is how the week reads at a glance. ` +
|
|
391
|
+
`Then read_desk_run(id) for a run's steps in full.`,
|
|
392
|
+
{
|
|
393
|
+
workspace: z.string().optional().describe("Only this workspace"),
|
|
394
|
+
flow: z.string().optional().describe("Only runs of this flow"),
|
|
395
|
+
status: z.enum(["completed", "failed"]).optional(),
|
|
396
|
+
since: z.string().optional().describe("ISO date; only runs started on or after it"),
|
|
397
|
+
limit: z.number().int().min(1).max(200).optional().describe("How many (default 30)"),
|
|
398
|
+
},
|
|
399
|
+
async (args) => {
|
|
400
|
+
const rows = digest
|
|
401
|
+
.filter((r) => !args.workspace || r.workspace === args.workspace)
|
|
402
|
+
.filter((r) => !args.flow || r.flow === args.flow)
|
|
403
|
+
.filter((r) => !args.status || r.status === args.status)
|
|
404
|
+
.filter((r) => !args.since || r.startedAt >= args.since)
|
|
405
|
+
.slice(0, args.limit ?? 30)
|
|
406
|
+
.map(
|
|
407
|
+
(r) =>
|
|
408
|
+
`${(r.workspace ?? "?").padEnd(12)} ${r.id} ${r.flow} ${r.startedAt.slice(0, 16)} ${r.status} $${r.costUsd.toFixed(3)} ${r.summary ?? "(no summary)"}`,
|
|
409
|
+
);
|
|
410
|
+
return { content: [{ type: "text" as const, text: rows.length ? rows.join("\n") : "no runs match" }] };
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
const read = tool(
|
|
414
|
+
"read_desk_run",
|
|
415
|
+
"One run from another workspace in full: each step's agent, status and what it replied (long replies trimmed).",
|
|
416
|
+
{ id: z.string().describe("A run id from recall_desk_runs") },
|
|
417
|
+
async (args) => {
|
|
418
|
+
const r = digest.find((d) => d.id === args.id);
|
|
419
|
+
if (!r) return { content: [{ type: "text" as const, text: `no run ${args.id} in the account's recent history` }], isError: true };
|
|
420
|
+
const text =
|
|
421
|
+
`${r.workspace ?? "?"} · ${r.flow} · ${r.status} · started ${r.startedAt}${r.finishedAt ? ` · finished ${r.finishedAt}` : ""} · $${r.costUsd.toFixed(4)}\n` +
|
|
422
|
+
(r.summary ? `summary: ${r.summary}\n` : "") +
|
|
423
|
+
r.steps.map((s) => `\n## ${s.agent} (${s.status})\n${s.result ?? "(no reply)"}`).join("\n");
|
|
424
|
+
return { content: [{ type: "text" as const, text }] };
|
|
425
|
+
},
|
|
426
|
+
);
|
|
427
|
+
return {
|
|
428
|
+
server: createSdkMcpServer({ name: "foldrun_desks", version: "1.0.0", tools: [recall, read] }),
|
|
429
|
+
toolNames: ["mcp__foldrun_desks__recall_desk_runs", "mcp__foldrun_desks__read_desk_run"],
|
|
430
|
+
promptLines: [
|
|
431
|
+
`- **recall_desk_runs()** / **read_desk_run(id)** — what the account's other workspaces ` +
|
|
432
|
+
`(${workspaces.join(", ") || "none yet"}) concluded, newest first. Their run records, not their files.`,
|
|
433
|
+
],
|
|
434
|
+
};
|
|
435
|
+
}
|