@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.
Files changed (166) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -0
  3. package/dist/index.d.ts +32 -0
  4. package/dist/index.js +43 -0
  5. package/dist/src/agent-tools.d.ts +31 -0
  6. package/dist/src/agent-tools.js +99 -0
  7. package/dist/src/agents-md.d.ts +9 -0
  8. package/dist/src/agents-md.js +83 -0
  9. package/dist/src/api-tools.d.ts +38 -0
  10. package/dist/src/api-tools.js +313 -0
  11. package/dist/src/approvals.d.ts +39 -0
  12. package/dist/src/approvals.js +103 -0
  13. package/dist/src/arrange.d.ts +8 -0
  14. package/dist/src/arrange.js +40 -0
  15. package/dist/src/catalog.d.ts +108 -0
  16. package/dist/src/catalog.js +255 -0
  17. package/dist/src/completions.d.ts +61 -0
  18. package/dist/src/completions.js +578 -0
  19. package/dist/src/confine.d.ts +31 -0
  20. package/dist/src/confine.js +298 -0
  21. package/dist/src/container.d.ts +30 -0
  22. package/dist/src/container.js +172 -0
  23. package/dist/src/context-tools.d.ts +81 -0
  24. package/dist/src/context-tools.js +347 -0
  25. package/dist/src/deploy.d.ts +100 -0
  26. package/dist/src/deploy.js +377 -0
  27. package/dist/src/egress.d.ts +75 -0
  28. package/dist/src/egress.js +133 -0
  29. package/dist/src/evals.d.ts +150 -0
  30. package/dist/src/evals.js +651 -0
  31. package/dist/src/flow-lint.d.ts +16 -0
  32. package/dist/src/flow-lint.js +177 -0
  33. package/dist/src/git-hook.d.ts +1 -0
  34. package/dist/src/git-hook.js +70 -0
  35. package/dist/src/git.d.ts +37 -0
  36. package/dist/src/git.js +101 -0
  37. package/dist/src/gitrepo.d.ts +95 -0
  38. package/dist/src/gitrepo.js +355 -0
  39. package/dist/src/history.d.ts +82 -0
  40. package/dist/src/history.js +259 -0
  41. package/dist/src/kinds.d.ts +95 -0
  42. package/dist/src/kinds.js +421 -0
  43. package/dist/src/library.d.ts +94 -0
  44. package/dist/src/library.js +341 -0
  45. package/dist/src/linkable.d.ts +20 -0
  46. package/dist/src/linkable.js +113 -0
  47. package/dist/src/model-probe.d.ts +21 -0
  48. package/dist/src/model-probe.js +81 -0
  49. package/dist/src/notify.d.ts +61 -0
  50. package/dist/src/notify.js +252 -0
  51. package/dist/src/oauth-presets.d.ts +13 -0
  52. package/dist/src/oauth-presets.js +39 -0
  53. package/dist/src/okf.d.ts +220 -0
  54. package/dist/src/okf.js +656 -0
  55. package/dist/src/openapi.d.ts +66 -0
  56. package/dist/src/openapi.js +342 -0
  57. package/dist/src/paths.d.ts +33 -0
  58. package/dist/src/paths.js +78 -0
  59. package/dist/src/platform.d.ts +32 -0
  60. package/dist/src/platform.js +48 -0
  61. package/dist/src/providers.d.ts +47 -0
  62. package/dist/src/providers.js +114 -0
  63. package/dist/src/refs.d.ts +20 -0
  64. package/dist/src/refs.js +78 -0
  65. package/dist/src/run-container.d.ts +227 -0
  66. package/dist/src/run-container.js +760 -0
  67. package/dist/src/runner.d.ts +241 -0
  68. package/dist/src/runner.js +3215 -0
  69. package/dist/src/runtime.d.ts +41 -0
  70. package/dist/src/runtime.js +357 -0
  71. package/dist/src/script-tools.d.ts +62 -0
  72. package/dist/src/script-tools.js +296 -0
  73. package/dist/src/secret-files.d.ts +12 -0
  74. package/dist/src/secret-files.js +92 -0
  75. package/dist/src/secrets.d.ts +147 -0
  76. package/dist/src/secrets.js +626 -0
  77. package/dist/src/starter.d.ts +21 -0
  78. package/dist/src/starter.js +399 -0
  79. package/dist/src/step-exec.d.ts +136 -0
  80. package/dist/src/step-exec.js +391 -0
  81. package/dist/src/storage.d.ts +123 -0
  82. package/dist/src/storage.js +640 -0
  83. package/dist/src/store.d.ts +1052 -0
  84. package/dist/src/store.js +2378 -0
  85. package/dist/src/tar.d.ts +30 -0
  86. package/dist/src/tar.js +111 -0
  87. package/dist/src/tool-names.d.ts +39 -0
  88. package/dist/src/tool-names.js +79 -0
  89. package/dist/src/tool-programs.d.ts +18 -0
  90. package/dist/src/tool-programs.js +52 -0
  91. package/dist/src/tool-test.d.ts +19 -0
  92. package/dist/src/tool-test.js +335 -0
  93. package/dist/src/translator.d.ts +159 -0
  94. package/dist/src/translator.js +986 -0
  95. package/dist/src/triggers.d.ts +60 -0
  96. package/dist/src/triggers.js +270 -0
  97. package/dist/src/webhook.d.ts +47 -0
  98. package/dist/src/webhook.js +148 -0
  99. package/index.ts +44 -0
  100. package/package.json +64 -0
  101. package/src/agent-tools.ts +138 -0
  102. package/src/agents-md.ts +81 -0
  103. package/src/api-tools.ts +389 -0
  104. package/src/approvals.ts +129 -0
  105. package/src/arrange.ts +42 -0
  106. package/src/catalog.ts +321 -0
  107. package/src/completions.ts +652 -0
  108. package/src/confine.ts +350 -0
  109. package/src/container.ts +220 -0
  110. package/src/context-tools.ts +435 -0
  111. package/src/deploy.ts +458 -0
  112. package/src/egress.ts +176 -0
  113. package/src/evals.ts +812 -0
  114. package/src/flow-lint.ts +215 -0
  115. package/src/git-hook.ts +73 -0
  116. package/src/git.ts +117 -0
  117. package/src/gitrepo.ts +391 -0
  118. package/src/history.ts +329 -0
  119. package/src/kinds.ts +507 -0
  120. package/src/library.ts +419 -0
  121. package/src/linkable.ts +123 -0
  122. package/src/model-probe.ts +112 -0
  123. package/src/notify.ts +281 -0
  124. package/src/oauth-presets.ts +54 -0
  125. package/src/okf.ts +768 -0
  126. package/src/openapi.ts +383 -0
  127. package/src/paths.ts +79 -0
  128. package/src/platform.ts +87 -0
  129. package/src/providers.ts +147 -0
  130. package/src/refs.ts +83 -0
  131. package/src/run-container.ts +883 -0
  132. package/src/runner.ts +3446 -0
  133. package/src/runtime.ts +404 -0
  134. package/src/script-tools.ts +383 -0
  135. package/src/secret-files.ts +101 -0
  136. package/src/secrets.ts +799 -0
  137. package/src/starter.ts +415 -0
  138. package/src/step-exec.ts +498 -0
  139. package/src/storage.ts +771 -0
  140. package/src/store.ts +2985 -0
  141. package/src/tar.ts +124 -0
  142. package/src/tool-names.ts +91 -0
  143. package/src/tool-programs.ts +65 -0
  144. package/src/tool-test.ts +401 -0
  145. package/src/translator.ts +1029 -0
  146. package/src/triggers.ts +287 -0
  147. package/src/webhook.ts +183 -0
  148. package/templates/blog-desk/AGENTS.md +13 -0
  149. package/templates/blog-desk/agents/researcher/agent.md +11 -0
  150. package/templates/blog-desk/agents/writer/agent.md +8 -0
  151. package/templates/blog-desk/evals/writer-quality.md +10 -0
  152. package/templates/blog-desk/flows/publish.md +10 -0
  153. package/templates/blog-desk/knowledge/house-style.md +9 -0
  154. package/templates/blog-desk/knowledge/index.md +9 -0
  155. package/templates/hello/AGENTS.md +18 -0
  156. package/templates/hello/agents/curator/agent.md +32 -0
  157. package/templates/hello/agents/notetaker/agent.md +24 -0
  158. package/templates/hello/evals/house-style.md +16 -0
  159. package/templates/hello/flows/memory-review.md +7 -0
  160. package/templates/hello/flows/note.md +7 -0
  161. package/templates/hello/knowledge/house-style.md +15 -0
  162. package/templates/hello/knowledge/index.md +9 -0
  163. package/templates/hello/skills/plain-english/SKILL.md +11 -0
  164. package/templates/hello/state/publishing.md +7 -0
  165. package/templates/hello/tools/wordcount/run.py +13 -0
  166. package/templates/hello/tools/wordcount/tool.md +22 -0
package/src/history.ts ADDED
@@ -0,0 +1,329 @@
1
+ // Every change to a workspace, kept.
2
+ //
3
+ // A workspace is a few hundred kilobytes of markdown, and until now the only
4
+ // record of a change was the file after it. A save from the dashboard, a
5
+ // deploy from git, an agent writing memory — each overwrote what was there
6
+ // and nothing remembered the before. Two things were impossible as a result:
7
+ // answering "what did this look like yesterday", and attributing a change in
8
+ // an eval score to the change in a file that caused it.
9
+ //
10
+ // So every write records a REVISION: what changed, from what, to what, by
11
+ // whom, and why. A deploy from git records one revision for the whole push
12
+ // with the commit as its id; a dashboard save records one for the file with
13
+ // a local id. Observe attributes eval scores to revision ids, so the "this
14
+ // change made it worse" table works for a workspace that has never seen a
15
+ // git repository — and when one is connected later, its commits slot into
16
+ // the same series.
17
+ //
18
+ // No git binary, no working copy, no repository on the server. The platform
19
+ // image deliberately has none (deploy.ts explains why); this is a journal of
20
+ // content, which is all the questions above need. Connecting a repository
21
+ // is a separate step and pushes THIS history out, not the other way round.
22
+ //
23
+ // Storage: <data>/<tenant>/.history/<scope>/<id>.json, one file per revision,
24
+ // plus index.jsonl — one line each, newest last — so listing does not open
25
+ // every revision. Content is stored inline: the files are small, and a
26
+ // content-addressed store would be machinery in search of a problem.
27
+
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+ import crypto from "node:crypto";
31
+ import { dataRoot } from "./paths.ts";
32
+ import {
33
+ commitChanges,
34
+ gitAvailable,
35
+ headSha,
36
+ listCommits,
37
+ readCommit,
38
+ repoExists,
39
+ } from "./gitrepo.ts";
40
+
41
+ /**
42
+ * The current tree of a scope, so the first commit can be a full import.
43
+ *
44
+ * Registered by the modules that know how to list a workspace or the library
45
+ * (store.ts, library.ts), because this module cannot import them — they
46
+ * import it. A revision recorded before any commit exists imports the whole
47
+ * tree rather than committing one file into an empty repository, so a clone
48
+ * made a minute later has everything.
49
+ */
50
+ type TreeReader = (tenant: string, scope: string) => RevisionFile[] | null;
51
+ let readTree: TreeReader = () => null;
52
+ export function registerTreeReader(fn: TreeReader) {
53
+ const prev = readTree;
54
+ readTree = (tenant, scope) => fn(tenant, scope) ?? prev(tenant, scope);
55
+ }
56
+
57
+ /** Something to do after a commit lands on main — the mirror push. Registered
58
+ * by mirror.ts; this module cannot import it (secrets, settings). */
59
+ type AfterCommit = (tenant: string, scope: string, sha: string) => void;
60
+ const afterCommit: AfterCommit[] = [];
61
+ export function registerAfterCommit(fn: AfterCommit) {
62
+ afterCommit.push(fn);
63
+ }
64
+ export function notifyCommit(tenant: string, scope: string, sha: string) {
65
+ for (const fn of afterCommit) {
66
+ try {
67
+ fn(tenant, scope, sha);
68
+ } catch {
69
+ // a mirror that fails must not fail the save that triggered it
70
+ }
71
+ }
72
+ }
73
+
74
+ /** Journal or git? Git whenever the binary exists; the journal is the
75
+ * fallback for an install without one. An install that started on the
76
+ * journal keeps it readable — both are consulted on read. */
77
+ const useGit = () => process.env.FOLDRUN_HISTORY !== "journal" && gitAvailable();
78
+
79
+ // Not store's assertSafeName: store imports this module, and a cycle between
80
+ // the two is avoidable for one regex.
81
+ function assertSafeName(v: string, what: string) {
82
+ if (!/^[A-Za-z0-9][A-Za-z0-9._@-]{0,120}$/.test(v)) throw new Error(`illegal ${what}: ${v}`);
83
+ }
84
+
85
+ export interface RevisionFile {
86
+ path: string;
87
+ /** Null when the file did not exist before. */
88
+ before: string | null;
89
+ /** Null when the change deleted it. */
90
+ after: string | null;
91
+ }
92
+
93
+ export interface Revision {
94
+ id: string;
95
+ at: string;
96
+ /** Who: a signed-in email, "api-key", "agent:<name>", "deploy", "system". */
97
+ by: string;
98
+ message: string;
99
+ /** Set when this revision IS a git commit — a deploy from a push. */
100
+ commit: string | null;
101
+ files: RevisionFile[];
102
+ }
103
+
104
+ /** The index line: everything but the content. */
105
+ export interface RevisionSummary {
106
+ id: string;
107
+ at: string;
108
+ by: string;
109
+ message: string;
110
+ commit: string | null;
111
+ paths: string[];
112
+ }
113
+
114
+ /** Anything over this is not a document and is recorded by name only. */
115
+ const MAX_INLINE = 256 * 1024;
116
+
117
+ const historyDir = (tenant: string, scope: string) => {
118
+ assertSafeName(tenant, "tenant");
119
+ // "@library" for the account shelf; a workspace name otherwise.
120
+ if (scope !== "@library") assertSafeName(scope, "workspace");
121
+ return path.join(dataRoot(), tenant, ".history", scope);
122
+ };
123
+
124
+ /** A local revision id: sortable by time, unique enough, visibly not a sha. */
125
+ export function newRevisionId(now = new Date()): string {
126
+ return `r-${now.getTime().toString(36)}-${crypto.randomBytes(3).toString("hex")}`;
127
+ }
128
+
129
+ /**
130
+ * Record one change set. Called by the writers, never by hand.
131
+ *
132
+ * Returns null and records nothing when nothing actually changed — a save
133
+ * of identical content is not a revision, and a history full of "no change"
134
+ * entries is a history nobody reads.
135
+ */
136
+ export function recordRevision(
137
+ tenant: string,
138
+ scope: string,
139
+ files: RevisionFile[],
140
+ meta: { by?: string; message?: string; id?: string; commit?: string | null } = {},
141
+ ): Revision | null {
142
+ const changed = files.filter((f) => f.before !== f.after);
143
+ if (changed.length === 0) return null;
144
+
145
+ if (useGit()) {
146
+ // First commit ever for this scope: import the whole tree as it stands
147
+ // now (the writer has already written), so the repository is complete
148
+ // from its first commit rather than starting with one file.
149
+ const first = !headSha(tenant, scope);
150
+ // Generated bundle files (knowledge/index.md, memory/log.md) are rewritten
151
+ // by the same save that changed a concept, outside this call. Commit them
152
+ // as they now stand, or a clone's index disagrees with its own files —
153
+ // the exact drift the template tests exist to catch.
154
+ const generated = first
155
+ ? []
156
+ : (readTree(tenant, scope) ?? []).filter((f) => /(^|\/)(knowledge|memory)\/(.*\/)?(index|log)\.md$/.test(f.path));
157
+ const toCommit = first ? (readTree(tenant, scope) ?? changed) : [...changed, ...generated];
158
+ const sha = commitChanges(tenant, scope, toCommit, {
159
+ by: meta.by,
160
+ // Named after what the caller changed, not the generated files that
161
+ // rode along — "edited knowledge/style.md", not "changed 3 files".
162
+ message: first ? `initial import${meta.message ? ` — ${meta.message}` : ""}` : (meta.message ?? describe(changed)),
163
+ });
164
+ if (!sha) return null;
165
+ notifyCommit(tenant, scope, sha);
166
+ return {
167
+ id: meta.commit ?? sha,
168
+ at: new Date().toISOString(),
169
+ by: meta.by ?? "system",
170
+ message: meta.message ?? describe(changed),
171
+ commit: sha,
172
+ files: changed,
173
+ };
174
+ }
175
+
176
+ const dir = historyDir(tenant, scope);
177
+ fs.mkdirSync(dir, { recursive: true });
178
+ const rev: Revision = {
179
+ id: meta.id ?? meta.commit ?? newRevisionId(),
180
+ at: new Date().toISOString(),
181
+ by: meta.by ?? "system",
182
+ message: meta.message ?? describe(changed),
183
+ commit: meta.commit ?? null,
184
+ files: changed.map((f) => ({
185
+ path: f.path,
186
+ before: clip(f.before),
187
+ after: clip(f.after),
188
+ })),
189
+ };
190
+ assertSafeName(rev.id, "revision id");
191
+ fs.writeFileSync(path.join(dir, `${rev.id}.json`), JSON.stringify(rev));
192
+ const line: RevisionSummary = {
193
+ id: rev.id,
194
+ at: rev.at,
195
+ by: rev.by,
196
+ message: rev.message,
197
+ commit: rev.commit,
198
+ paths: rev.files.map((f) => f.path),
199
+ };
200
+ fs.appendFileSync(path.join(dir, "index.jsonl"), `${JSON.stringify(line)}\n`);
201
+ return rev;
202
+ }
203
+
204
+ const clip = (s: string | null) =>
205
+ s === null ? null : s.length > MAX_INLINE ? `[${s.length} bytes — too large to keep inline]` : s;
206
+
207
+ /** "edited tools/x/run.py", "created 3 files", "deleted flows/old.md". */
208
+ function describe(files: RevisionFile[]): string {
209
+ if (files.length === 1) {
210
+ const f = files[0];
211
+ const verb = f.before === null ? "created" : f.after === null ? "deleted" : "edited";
212
+ return `${verb} ${f.path}`;
213
+ }
214
+ const created = files.filter((f) => f.before === null).length;
215
+ const deleted = files.filter((f) => f.after === null).length;
216
+ const edited = files.length - created - deleted;
217
+ return [edited && `edited ${edited}`, created && `created ${created}`, deleted && `deleted ${deleted}`]
218
+ .filter(Boolean)
219
+ .join(", ") + ` file${files.length === 1 ? "" : "s"}`;
220
+ }
221
+
222
+ /** Newest first. `forPath` narrows to revisions that touched one file. */
223
+ export function listRevisions(
224
+ tenant: string,
225
+ scope: string,
226
+ opts: { forPath?: string; limit?: number } = {},
227
+ ): RevisionSummary[] {
228
+ if (useGit() && repoExists(tenant, scope)) {
229
+ const fromGit = listCommits(tenant, scope, opts);
230
+ // Anything the journal holds from before the repository existed comes
231
+ // after git's rows: older, and still worth showing.
232
+ return [...fromGit, ...journalRevisions(tenant, scope, opts)].slice(0, opts.limit ?? 200);
233
+ }
234
+ return journalRevisions(tenant, scope, opts);
235
+ }
236
+
237
+ function journalRevisions(
238
+ tenant: string,
239
+ scope: string,
240
+ opts: { forPath?: string; limit?: number } = {},
241
+ ): RevisionSummary[] {
242
+ const file = path.join(historyDir(tenant, scope), "index.jsonl");
243
+ if (!fs.existsSync(file)) return [];
244
+ const all = fs
245
+ .readFileSync(file, "utf8")
246
+ .split("\n")
247
+ .filter(Boolean)
248
+ .flatMap((l) => {
249
+ try {
250
+ return [JSON.parse(l) as RevisionSummary];
251
+ } catch {
252
+ return []; // a torn line from a crash mid-append loses one entry, not the file
253
+ }
254
+ })
255
+ .reverse();
256
+ const narrowed = opts.forPath ? all.filter((r) => r.paths.includes(opts.forPath!)) : all;
257
+ return narrowed.slice(0, opts.limit ?? 200);
258
+ }
259
+
260
+ export function readRevision(tenant: string, scope: string, id: string, forPath?: string): Revision | null {
261
+ assertSafeName(id, "revision id");
262
+ if (useGit() && repoExists(tenant, scope) && /^[0-9a-f]{7,40}$/.test(id)) {
263
+ const fromGit = readCommit(tenant, scope, id, forPath);
264
+ if (fromGit) return fromGit;
265
+ }
266
+ const file = path.join(historyDir(tenant, scope), `${id}.json`);
267
+ if (!fs.existsSync(file)) return null;
268
+ return JSON.parse(fs.readFileSync(file, "utf8")) as Revision;
269
+ }
270
+
271
+ /**
272
+ * Make sure a scope's repository has its initial import, so a clone made
273
+ * right now is complete. A no-op once any commit exists.
274
+ */
275
+ export function ensureImported(tenant: string, scope: string): string | null {
276
+ if (!useGit()) return null;
277
+ if (headSha(tenant, scope)) return headSha(tenant, scope);
278
+ const tree = readTree(tenant, scope);
279
+ if (!tree || tree.length === 0) return null;
280
+ return commitChanges(tenant, scope, tree, { by: "foldrun", message: "initial import" });
281
+ }
282
+
283
+ /** The most recent revision id for a scope — what a change is attributed
284
+ * to when nothing more specific (a deployed commit) is on record. */
285
+ export function latestRevisionId(tenant: string, scope: string): string | null {
286
+ return listRevisions(tenant, scope, { limit: 1 })[0]?.id ?? null;
287
+ }
288
+
289
+ // ------------------------------------------------------------------- diff
290
+
291
+ export type DiffOp = { kind: "same" | "add" | "del"; text: string };
292
+
293
+ /**
294
+ * A line diff, for showing a revision. Plain LCS — quadratic, and fine: the
295
+ * files are documents, not generated code, and a diff of two 300-line files
296
+ * is ninety thousand cells, which is nothing.
297
+ */
298
+ export function diffLines(before: string, after: string): DiffOp[] {
299
+ const a = before.split("\n");
300
+ const b = after.split("\n");
301
+ const n = a.length;
302
+ const m = b.length;
303
+ // lcs[i][j] = length of the LCS of a[i..] and b[j..]
304
+ const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
305
+ for (let i = n - 1; i >= 0; i--) {
306
+ for (let j = m - 1; j >= 0; j--) {
307
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
308
+ }
309
+ }
310
+ const out: DiffOp[] = [];
311
+ let i = 0;
312
+ let j = 0;
313
+ while (i < n && j < m) {
314
+ if (a[i] === b[j]) {
315
+ out.push({ kind: "same", text: a[i] });
316
+ i++;
317
+ j++;
318
+ } else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
319
+ out.push({ kind: "del", text: a[i] });
320
+ i++;
321
+ } else {
322
+ out.push({ kind: "add", text: b[j] });
323
+ j++;
324
+ }
325
+ }
326
+ while (i < n) out.push({ kind: "del", text: a[i++] });
327
+ while (j < m) out.push({ kind: "add", text: b[j++] });
328
+ return out;
329
+ }