glove-foundry 0.5.1 → 0.5.2

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 (39) hide show
  1. package/README.md +27 -15
  2. package/dist/{chunk-KBMD4RYU.js → chunk-6Q2LGPSC.js} +49 -198
  3. package/dist/{chunk-GKZLWD5M.js → chunk-7F7QVO6I.js} +4 -1
  4. package/dist/{chunk-SGF3CFUR.js → chunk-D2AHSYOB.js} +71 -8
  5. package/dist/{chunk-TH5ETCG2.js → chunk-MABLKZVO.js} +13 -4
  6. package/dist/{chunk-LW5PJ2FN.js → chunk-NHV64WNO.js} +1 -1
  7. package/dist/{chunk-5NFLGHOC.js → chunk-PVOKR4CN.js} +211 -29
  8. package/dist/cli.js +7 -5
  9. package/dist/{client-DcsnudMI.d.ts → client-CLTds1uV.d.ts} +13 -1
  10. package/dist/client.d.ts +1 -1
  11. package/dist/client.js +1 -1
  12. package/dist/execution-agent.js +4 -4
  13. package/dist/execution-daemon.js +1 -1
  14. package/dist/execution-loader.js +4 -4
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +14 -14
  17. package/docs/building-with-foundry.md +4 -2
  18. package/docs/release-verification.md +1 -1
  19. package/package.json +6 -6
  20. package/templates/minimal/README.md +34 -8
  21. package/templates/minimal/agents/assistant/agent.ts +24 -10
  22. package/templates/minimal/foundry.application.ts +11 -3
  23. package/templates/minimal/gitignore +1 -0
  24. package/templates/minimal/lib/file-store.ts +95 -0
  25. package/templates/travel-concierge/README.md +46 -12
  26. package/templates/travel-concierge/agents/concierge/agent.ts +10 -4
  27. package/templates/travel-concierge/agents/concierge/composition.ts +9 -2
  28. package/templates/travel-concierge/agents/concierge/flights.ts +43 -0
  29. package/templates/travel-concierge/agents/concierge/instances.ts +42 -0
  30. package/templates/travel-concierge/agents/concierge/memory/traveller.memory.ts +13 -8
  31. package/templates/travel-concierge/agents/concierge/tools/convert-currency.tool.ts +34 -0
  32. package/templates/travel-concierge/agents/concierge/workbench.ts +4 -1
  33. package/templates/travel-concierge/foundry.application.ts +15 -6
  34. package/templates/travel-concierge/gitignore +1 -0
  35. package/templates/travel-concierge/lib/demo-model.ts +89 -25
  36. package/templates/travel-concierge/lib/file-context.ts +60 -0
  37. package/templates/travel-concierge/lib/file-store.ts +95 -0
  38. package/templates/minimal/agents/assistant/tools/current-time.tool.ts +0 -17
  39. package/templates/travel-concierge/agents/concierge/tools/find-flights.tool.ts +0 -49
@@ -3,15 +3,22 @@ import calendar from "./apps/calendar.app.js";
3
3
  import tripContext from "./layers/trip-context.layer.js";
4
4
  import travellerMemory from "./memory/traveller.memory.js";
5
5
  import usage from "./subscribers/usage.subscriber.js";
6
- import findFlights from "./tools/find-flights.tool.js";
6
+ import convertCurrency from "./tools/convert-currency.tool.js";
7
7
 
8
8
  /**
9
+ * The catalogue: everything this agent's instances are *allowed* to use.
10
+ *
11
+ * - `calendar` and `convertCurrency` are installable. They mount only on an
12
+ * instance that installs them (see instances.ts).
13
+ * - `travellerMemory`, `tripContext` and `usage` are definition surfaces; they
14
+ * are also selected in agent.ts (`memory`, `layers`, `subscribers`).
15
+ *
9
16
  * Composition is by value, not by string id. Rename a file and the import
10
17
  * breaks at compile time instead of failing at runtime.
11
18
  */
12
19
  export const conciergeComponents = composeAgent(
13
20
  calendar,
14
- findFlights,
21
+ convertCurrency,
15
22
  travellerMemory,
16
23
  tripContext,
17
24
  usage,
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Searching flights is the concierge's core job, so the tool lives in the
5
+ * agent's `tools` field: every instance has it, nothing to install. Compare
6
+ * `tools/convert-currency.tool.ts`, which an instance opts into.
7
+ *
8
+ * The sample data is deliberately local so a fresh project runs offline.
9
+ * Replace `search` with a call to your own inventory or a provider API.
10
+ */
11
+ const SAMPLE_FLIGHTS = [
12
+ { carrier: "Kestrel Air", from: "LIS", to: "NBO", depart: "08:40", hours: 9.5, price: 612 },
13
+ { carrier: "Meridian", from: "LIS", to: "NBO", depart: "13:15", hours: 11, price: 494 },
14
+ { carrier: "Northwind", from: "LIS", to: "NBO", depart: "22:05", hours: 9, price: 738 },
15
+ ] as const;
16
+
17
+ function search(from: string, to: string, maxPrice: number) {
18
+ const origin = from.trim().toUpperCase();
19
+ const destination = to.trim().toUpperCase();
20
+ return SAMPLE_FLIGHTS.filter((flight) =>
21
+ (!origin || flight.from === origin) &&
22
+ (!destination || flight.to === destination) &&
23
+ flight.price <= maxPrice
24
+ );
25
+ }
26
+
27
+ export const findFlights = {
28
+ name: "find_flights",
29
+ description:
30
+ "Search flights between two IATA airport codes. Returns carrier, departure time, duration and price in USD.",
31
+ inputSchema: z.object({
32
+ from: z.string().describe("Origin IATA code, for example LIS"),
33
+ to: z.string().describe("Destination IATA code, for example NBO"),
34
+ maxPrice: z.number().default(5_000).describe("Highest acceptable price in USD"),
35
+ }),
36
+ async do({ from, to, maxPrice }: { from: string; to: string; maxPrice: number }) {
37
+ const matches = search(from, to, maxPrice);
38
+ if (matches.length === 0) {
39
+ return { status: "success" as const, data: { matches: [], note: "No flight matched those constraints." } };
40
+ }
41
+ return { status: "success" as const, data: { matches } };
42
+ },
43
+ };
@@ -0,0 +1,42 @@
1
+ import { defineAgentInstance, defineBinding, install } from "glove-foundry";
2
+ import concierge from "./agent.js";
3
+ import calendar from "./apps/calendar.app.js";
4
+ import convertCurrency from "./tools/convert-currency.tool.js";
5
+ import { travellerAccount, travellerOutbound } from "./topology.js";
6
+ import messaging, { sendMessage } from "./transmissions/messaging.transmission.js";
7
+
8
+ /**
9
+ * A ready-made instance, seeded into the data adapter on first start
10
+ * (foundry.application.ts). It has what a new instance created from the
11
+ * inspector does not: installed capabilities and permission to reply.
12
+ *
13
+ * Choose `concierge-demo` in the inspector's run drawer or WebChat to use it.
14
+ */
15
+ export const conciergeDemo = defineAgentInstance(concierge, {
16
+ id: "concierge-demo",
17
+ workspaceId: "demo",
18
+ context: { budgetUsd: 2_500 },
19
+ installations: [
20
+ install(convertCurrency, { home: "EUR" }),
21
+ install(calendar, { calendarId: "primary", timezone: "Europe/Lisbon" }, { account: travellerAccount }),
22
+ ],
23
+ // Filled in by agent.ts's `playbooks` resolver on this instance's first run.
24
+ playbooks: [],
25
+ });
26
+
27
+ /**
28
+ * Outbound messages need a binding: the runtime's record that this instance
29
+ * may send through this route. Without one, a reply fails with
30
+ * "agent has no enabled bindings".
31
+ */
32
+ export const conciergeDemoReplies = defineBinding({
33
+ id: "concierge-demo-replies",
34
+ agent: conciergeDemo,
35
+ application: calendar,
36
+ transmission: messaging,
37
+ account: travellerAccount,
38
+ route: travellerOutbound,
39
+ capabilities: [sendMessage],
40
+ reply: { mode: "route", route: travellerOutbound },
41
+ enabled: true,
42
+ });
@@ -1,19 +1,24 @@
1
1
  import { Effect } from "effect";
2
2
  import { defineMemory } from "glove-foundry";
3
- import { MemorySchema } from "glove-memory/core";
4
- import { InMemoryContextAdapter } from "glove-memory/in-memory";
3
+ import { fileURLToPath } from "node:url";
4
+ import { FileContextAdapter } from "../../../lib/file-context.js";
5
5
 
6
6
  /**
7
- * Ambient context the concierge should carry between conversations: seat
8
- * preference, home airport, who is travelling. The adapter is replaceable —
9
- * point it at a durable store when you outgrow the in-memory one.
7
+ * Ambient context the concierge carries between conversations: seat
8
+ * preference, home airport, who is travelling. One file per agent instance, so
9
+ * two travellers never share a profile. The model reads it every turn and can
10
+ * update it with the context tools.
10
11
  */
11
- const schema = new MemorySchema();
12
- const context = new InMemoryContextAdapter({ schema });
12
+ const directory = new URL("../../../.data/memory/", import.meta.url);
13
13
 
14
14
  const travellerMemory = defineMemory({
15
15
  description: "What we already know about this traveller",
16
- context: { adapter: () => Effect.succeed(context) },
16
+ context: {
17
+ adapter: ({ agentId }) =>
18
+ Effect.promise(() =>
19
+ FileContextAdapter.open(fileURLToPath(new URL(`${encodeURIComponent(agentId)}.json`, directory))),
20
+ ),
21
+ },
17
22
  });
18
23
 
19
24
  export default travellerMemory;
@@ -0,0 +1,34 @@
1
+ import { Effect } from "effect";
2
+ import { defineSharedTool } from "glove-foundry";
3
+ import { z } from "zod";
4
+
5
+ /**
6
+ * A shared tool is installable: it is in the agent's catalogue (composition.ts)
7
+ * but mounts only on instances that install it — see `instances.ts`, where the
8
+ * demo instance installs it with a home currency. One file, one default export,
9
+ * and the filename is the id: `convert-currency`.
10
+ *
11
+ * The rates are fixed sample values. Replace `RATES` with a live rate source.
12
+ */
13
+ const RATES: Readonly<Record<string, number>> = { USD: 1, EUR: 0.92, GBP: 0.79, KES: 129 };
14
+
15
+ const convertCurrency = defineSharedTool({
16
+ description: "Convert a USD price into the traveller's home currency",
17
+ config: z.object({ home: z.enum(["USD", "EUR", "GBP", "KES"]).default("USD") }),
18
+ create: ({ config }) => Effect.sync(() => {
19
+ const rate = RATES[config.home]!;
20
+ return {
21
+ name: "convert_currency",
22
+ description: `Convert an amount in USD into ${config.home}, the traveller's home currency.`,
23
+ inputSchema: z.object({ amountUsd: z.number().describe("Price in USD") }),
24
+ async do({ amountUsd }: { amountUsd: number }) {
25
+ return {
26
+ status: "success" as const,
27
+ data: { currency: config.home, amount: Math.round(amountUsd * rate * 100) / 100, rate },
28
+ };
29
+ },
30
+ };
31
+ }),
32
+ });
33
+
34
+ export default convertCurrency;
@@ -1,5 +1,5 @@
1
1
  import { JsSession, defineFn } from "glove-js";
2
- import { defineRepl, defineWorkingEnvironment } from "glove-foundry";
2
+ import { defineRepl, defineWorkingEnvironment, foundryDataEnvironmentPersistence } from "glove-foundry";
3
3
  import { z } from "zod";
4
4
 
5
5
  /**
@@ -11,6 +11,9 @@ import { z } from "zod";
11
11
  */
12
12
  export const conciergeWorkspace = defineWorkingEnvironment({
13
13
  options: { limits: { maxVfsBytes: 16 * 1024 * 1024 } },
14
+ // The environment closes after every run; this snapshots its files into the
15
+ // data adapter and restores them on the next run of the same instance.
16
+ persistence: foundryDataEnvironmentPersistence({ scope: "agent" }),
14
17
  });
15
18
 
16
19
  export function conciergeRepl(actor: string, budgetUsd: number) {
@@ -1,18 +1,27 @@
1
- import { MemoryFoundryDataAdapter, defineApplication } from "glove-foundry";
1
+ import { FileFoundryDataAdapter, defineApplication } from "glove-foundry";
2
+ import { fileURLToPath } from "node:url";
3
+ import { conciergeDemo, conciergeDemoReplies } from "./agents/concierge/instances.js";
2
4
  import { travellerAccount, travellerInbound, travellerOutbound } from "./agents/concierge/topology.js";
3
5
 
4
6
  /**
5
7
  * The application is where runtime data enters: which adapter persists
6
- * instances, which accounts and routes exist. `MemoryFoundryDataAdapter` keeps
7
- * everything in process, so restarting `pnpm dev` starts clean. Swap it for
8
- * your own adapter to persist across restarts.
8
+ * instances, which accounts, routes and bindings exist.
9
+ *
10
+ * Instances, conversations, schedules, workspace data and the concierge's VFS
11
+ * are saved to `.data/foundry.json`, so they survive restarts — including the
12
+ * restart `dev` performs every time you save a file. `conciergeDemo` is seeded
13
+ * on first start. Delete `.data/` to start fresh. Use a database-backed adapter
14
+ * when several hosts share one runtime.
9
15
  */
10
- export const data = new MemoryFoundryDataAdapter();
16
+ export const data = new FileFoundryDataAdapter({
17
+ file: fileURLToPath(new URL("./.data/foundry.json", import.meta.url)),
18
+ agents: [conciergeDemo],
19
+ });
11
20
 
12
21
  export default defineApplication({
13
22
  name: "{{projectName}}",
14
23
  data,
15
24
  accounts: [travellerAccount],
16
25
  routes: [travellerInbound, travellerOutbound],
17
- bindings: [],
26
+ bindings: [conciergeDemoReplies],
18
27
  });
@@ -1,3 +1,4 @@
1
1
  node_modules
2
2
  .env.local
3
3
  .foundry/manifest.json
4
+ .data
@@ -12,8 +12,41 @@ import type {
12
12
  * project runs end to end — real runs, real tool calls, a real event trace in
13
13
  * the inspector — before you have configured a provider.
14
14
  *
15
+ * Each turn it searches flights, then answers. When the message arrived over
16
+ * the chat transport (see README, "Send it a chat message"), it also replies
17
+ * through the installed calendar app's messaging tool, which is what a real
18
+ * model does when the playbook asks it to.
19
+ *
15
20
  * Delete this file and the `model()` fallback in agent.ts once you have a key.
16
21
  */
22
+ const SEND_TOOL = "glove_app_calendar__messaging_send";
23
+
24
+ interface Inbound {
25
+ readonly threadId: string;
26
+ readonly text: string;
27
+ readonly routeId: string;
28
+ }
29
+
30
+ function unescapeXml(value: string): string {
31
+ return value
32
+ .replaceAll("&quot;", '"')
33
+ .replaceAll("&apos;", "'")
34
+ .replaceAll("&lt;", "<")
35
+ .replaceAll("&gt;", ">")
36
+ .replaceAll("&amp;", "&");
37
+ }
38
+
39
+ /** Read the event Foundry serialized into the turn for an inbound transmission. */
40
+ function inboundFrom(text: string): Inbound | undefined {
41
+ if (!text.includes('<transmission direction="inbound"')) return undefined;
42
+ const payload = /<payload format="json">([\s\S]*?)<\/payload>/.exec(text)?.[1];
43
+ const routeId = /<outbound route="([^"]+)"/.exec(text)?.[1];
44
+ if (!payload || !routeId) return undefined;
45
+ const event = JSON.parse(unescapeXml(payload)) as { threadId?: string; text?: string };
46
+ if (!event.threadId) return undefined;
47
+ return { threadId: event.threadId, text: event.text ?? "", routeId: unescapeXml(routeId) };
48
+ }
49
+
17
50
  export class DemoModel implements ModelAdapter {
18
51
  readonly name = "demo-model";
19
52
  private systemPrompt = "";
@@ -26,38 +59,49 @@ export class DemoModel implements ModelAdapter {
26
59
  request: PromptRequest,
27
60
  notify: NotifySubscribersFunction,
28
61
  ): Promise<ModelPromptResult> {
29
- const toolCompleted = request.messages.some(
30
- (message) => Array.isArray(message.tool_results) && message.tool_results.length > 0,
62
+ // Only this turn matters: everything from the latest user message onward.
63
+ let userIndex = request.messages.length - 1;
64
+ while (userIndex >= 0) {
65
+ const message = request.messages[userIndex]!;
66
+ if (message.sender === "user" && !message.tool_results?.length && !message.framework_context) break;
67
+ userIndex--;
68
+ }
69
+ const turn = request.messages.slice(Math.max(userIndex, 0));
70
+ const asked = request.messages[userIndex]?.text ?? "";
71
+ const called = new Set(
72
+ turn.flatMap((message) => message.tool_calls?.map((call) => call.tool_name) ?? []),
31
73
  );
74
+ const inbound = inboundFrom(asked);
75
+ const answer = [
76
+ `Here is what I found for "${inbound?.text ?? asked}".`,
77
+ "Three flights match. Meridian at 13:15 is the cheapest at $494; Northwind at 22:05 is the fastest at 9h.",
78
+ "Tell me your dates and I will check the calendar and hold them.",
79
+ ].join(" ");
32
80
 
33
- if (!toolCompleted) {
34
- // First turn: call a real tool so the trace shows tool_use -> result.
35
- // ToolCall's fields are optional, so notify() reads the local literals.
36
- const callId = "call_demo_flights";
37
- const toolName = "find_flights";
38
- const input = { from: "LIS", to: "NBO", maxPrice: 5_000 };
39
- const call: ToolCall = { id: callId, tool_name: toolName, input_args: input };
40
- await notify("tool_use", { id: callId, name: toolName, input });
41
- await notify("model_response_complete", {
42
- text: "",
43
- tool_calls: [call],
44
- stop_reason: "tool_use",
45
- tokens_in: 14,
46
- tokens_out: 6,
81
+ if (!called.has("find_flights")) {
82
+ return this.callTool(notify, "call_demo_flights", "find_flights", {
83
+ from: "LIS",
84
+ to: "NBO",
85
+ maxPrice: 5_000,
86
+ });
87
+ }
88
+
89
+ const canReply = request.tools?.some((tool) => tool.name === SEND_TOOL) ?? false;
90
+ if (inbound && canReply && !called.has(SEND_TOOL)) {
91
+ return this.callTool(notify, "call_demo_reply", SEND_TOOL, {
92
+ routeId: inbound.routeId,
93
+ payload: { threadId: inbound.threadId, text: answer },
47
94
  });
48
- const message: Message = { sender: "agent", text: "", tool_calls: [call] };
49
- return { messages: [message], tokens_in: 14, tokens_out: 6 };
50
95
  }
51
96
 
52
- const asked = [...request.messages]
53
- .reverse()
54
- .find((message) => message.sender === "user" && !message.tool_results?.length)?.text;
55
97
  const text = [
56
- `Here is what I found for "${asked ?? "your trip"}".`,
57
- "Three flights match. Meridian at 13:15 is the cheapest at $494; Northwind at 22:05 is the fastest at 9h.",
58
- "Tell me your dates and I will check the calendar and hold them.",
98
+ answer,
59
99
  "",
60
- "(This is the built-in demo model. Set OPENROUTER_API_KEY in .env.local for real answers.)",
100
+ inbound
101
+ ? canReply
102
+ ? "(Replied to the traveller's chat thread — see the [telegram] line in the terminal.)"
103
+ : "(This instance cannot reply over chat: install the calendar app and add a binding, as concierge-demo has.)"
104
+ : "(This is the built-in demo model. Set OPENROUTER_API_KEY in .env.local for real answers.)",
61
105
  ].join("\n");
62
106
  await notify("text_delta", { text });
63
107
  await notify("model_response_complete", {
@@ -69,4 +113,24 @@ export class DemoModel implements ModelAdapter {
69
113
  void this.systemPrompt;
70
114
  return { messages: [{ sender: "agent", text }], tokens_in: 32, tokens_out: 48 };
71
115
  }
116
+
117
+ private async callTool(
118
+ notify: NotifySubscribersFunction,
119
+ id: string,
120
+ name: string,
121
+ input: Record<string, unknown>,
122
+ ): Promise<ModelPromptResult> {
123
+ // ToolCall's fields are optional, so notify() reads the local values.
124
+ const call: ToolCall = { id, tool_name: name, input_args: input };
125
+ await notify("tool_use", { id, name, input });
126
+ await notify("model_response_complete", {
127
+ text: "",
128
+ tool_calls: [call],
129
+ stop_reason: "tool_use",
130
+ tokens_in: 14,
131
+ tokens_out: 6,
132
+ });
133
+ const message: Message = { sender: "agent", text: "", tool_calls: [call] };
134
+ return { messages: [message], tokens_in: 14, tokens_out: 6 };
135
+ }
72
136
  }
@@ -0,0 +1,60 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import {
4
+ InMemoryContextAdapter,
5
+ MemorySchema,
6
+ type ContextEntryInput,
7
+ type ContextEntryPatch,
8
+ type Provenance,
9
+ } from "glove-memory";
10
+
11
+ type ContextMemoryState = ReturnType<InMemoryContextAdapter["snapshot"]>;
12
+
13
+ /**
14
+ * Ambient context (preferences, home airport, who is travelling) saved to a
15
+ * JSON file. Like transcripts, memory has to live outside the run's process to
16
+ * be there on the next message. Single host; use a database adapter for more.
17
+ */
18
+ export class FileContextAdapter extends InMemoryContextAdapter {
19
+ private constructor(private readonly file: string, state?: ContextMemoryState) {
20
+ super({ schema: new MemorySchema(), identifier: `file-context:${file}`, ...(state ? { state } : {}) });
21
+ }
22
+
23
+ static async open(file: string): Promise<FileContextAdapter> {
24
+ try {
25
+ return new FileContextAdapter(file, JSON.parse(await readFile(file, "utf8")) as ContextMemoryState);
26
+ } catch (error) {
27
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return new FileContextAdapter(file);
28
+ throw error;
29
+ }
30
+ }
31
+
32
+ private async save(): Promise<void> {
33
+ await mkdir(dirname(this.file), { recursive: true });
34
+ const temporary = `${this.file}.${process.pid}.tmp`;
35
+ await writeFile(temporary, JSON.stringify(this.snapshot()));
36
+ await rename(temporary, this.file);
37
+ }
38
+
39
+ override async set(entry: ContextEntryInput, provenance: Provenance) {
40
+ const result = await super.set(entry, provenance);
41
+ await this.save();
42
+ return result;
43
+ }
44
+ override async update(id: string, patch: ContextEntryPatch, provenance: Provenance) {
45
+ await super.update(id, patch, provenance);
46
+ await this.save();
47
+ }
48
+ override async unset(id: string, provenance: Provenance) {
49
+ await super.unset(id, provenance);
50
+ await this.save();
51
+ }
52
+ override async setSection(section: string, entries: Array<Omit<ContextEntryInput, "section">>, provenance: Provenance) {
53
+ await super.setSection(section, entries, provenance);
54
+ await this.save();
55
+ }
56
+ override async unsetSection(section: string, provenance: Provenance) {
57
+ await super.unsetSection(section, provenance);
58
+ await this.save();
59
+ }
60
+ }
@@ -0,0 +1,95 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import {
6
+ MemoryStore,
7
+ type InboxItem,
8
+ type Message,
9
+ type Task,
10
+ type TokenConsumptionCounter,
11
+ } from "glove-core";
12
+
13
+ /** Transcripts live in `.data/transcripts`, beside this project's `lib` folder. */
14
+ const TRANSCRIPTS = fileURLToPath(new URL("../.data/transcripts/", import.meta.url));
15
+
16
+ interface SavedConversation {
17
+ readonly messages: Message[];
18
+ readonly turns: number;
19
+ readonly tokens: TokenConsumptionCounter;
20
+ readonly tasks: Task[];
21
+ readonly inbox: InboxItem[];
22
+ }
23
+
24
+ /**
25
+ * Foundry runs every message in a fresh process, so a plain `MemoryStore`
26
+ * would start each turn with no history. This one loads the conversation from
27
+ * a JSON file and rewrites it after every change.
28
+ *
29
+ * One host, one active run per conversation. Replace it with a database-backed
30
+ * StoreAdapter when several hosts share conversations.
31
+ */
32
+ export class FileStore extends MemoryStore {
33
+ private restoring = false;
34
+
35
+ private constructor(conversationId: string, private readonly file: string) {
36
+ super(conversationId);
37
+ }
38
+
39
+ static async open(conversationId: string, directory = TRANSCRIPTS): Promise<FileStore> {
40
+ const name = createHash("sha256").update(conversationId).digest("hex").slice(0, 32);
41
+ const store = new FileStore(conversationId, join(directory, `${name}.json`));
42
+ await store.restore();
43
+ return store;
44
+ }
45
+
46
+ private async restore(): Promise<void> {
47
+ let saved: SavedConversation;
48
+ try {
49
+ saved = JSON.parse(await readFile(this.file, "utf8")) as SavedConversation;
50
+ } catch (error) {
51
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
52
+ throw error;
53
+ }
54
+ this.restoring = true;
55
+ try {
56
+ await this.appendMessages(saved.messages);
57
+ await this.addTokens(saved.tokens);
58
+ for (let turn = 0; turn < saved.turns; turn++) await this.incrementTurn();
59
+ await this.addTasks(saved.tasks);
60
+ for (const item of saved.inbox) await this.addInboxItem(item);
61
+ } finally {
62
+ this.restoring = false;
63
+ }
64
+ }
65
+
66
+ private async save(): Promise<void> {
67
+ if (this.restoring) return;
68
+ const saved: SavedConversation = {
69
+ messages: await this.getMessages(),
70
+ turns: await this.getTurnCount(),
71
+ tokens: await this.getTokenConsumption(),
72
+ tasks: await this.getTasks(),
73
+ inbox: await this.getInboxItems(),
74
+ };
75
+ await mkdir(dirname(this.file), { recursive: true });
76
+ const temporary = `${this.file}.${process.pid}.tmp`;
77
+ await writeFile(temporary, JSON.stringify(saved));
78
+ await rename(temporary, this.file);
79
+ }
80
+
81
+ override async appendMessages(messages: Message[]) { await super.appendMessages(messages); await this.save(); }
82
+ override async addTokens(usage: TokenConsumptionCounter) { await super.addTokens(usage); await this.save(); }
83
+ override async incrementTurn() { await super.incrementTurn(); await this.save(); }
84
+ override async resetCounters() { await super.resetCounters(); await this.save(); }
85
+ override async addTasks(tasks: Task[]) { await super.addTasks(tasks); await this.save(); }
86
+ override async updateTask(id: string, updates: Partial<Pick<Task, "status" | "content" | "activeForm">>) {
87
+ await super.updateTask(id, updates);
88
+ await this.save();
89
+ }
90
+ override async addInboxItem(item: InboxItem) { await super.addInboxItem(item); await this.save(); }
91
+ override async updateInboxItem(id: string, updates: Partial<Pick<InboxItem, "status" | "response" | "resolved_at">>) {
92
+ await super.updateInboxItem(id, updates);
93
+ await this.save();
94
+ }
95
+ }
@@ -1,17 +0,0 @@
1
- import { defineSharedTool } from "glove-foundry";
2
- import { z } from "zod";
3
-
4
- /** One file, one default export. The filename is the id: `current-time`. */
5
- const currentTime = defineSharedTool({
6
- description: "Return the current ISO timestamp",
7
- tool: {
8
- name: "current_time",
9
- description: "Return the current ISO timestamp",
10
- inputSchema: z.object({}),
11
- async do() {
12
- return { status: "success" as const, data: new Date().toISOString() };
13
- },
14
- },
15
- });
16
-
17
- export default currentTime;
@@ -1,49 +0,0 @@
1
- import { defineSharedTool } from "glove-foundry";
2
- import { z } from "zod";
3
-
4
- /**
5
- * A shared tool is the smallest Foundry capability: one file, one default
6
- * export, and an id taken from the filename (`find-flights`). Nothing here
7
- * knows which agent will mount it.
8
- *
9
- * The sample data is deliberately local so a fresh project runs offline.
10
- * Replace `search` with a call to your own inventory or a provider API.
11
- */
12
- const SAMPLE_FLIGHTS = [
13
- { carrier: "Kestrel Air", from: "LIS", to: "NBO", depart: "08:40", hours: 9.5, price: 612 },
14
- { carrier: "Meridian", from: "LIS", to: "NBO", depart: "13:15", hours: 11, price: 494 },
15
- { carrier: "Northwind", from: "LIS", to: "NBO", depart: "22:05", hours: 9, price: 738 },
16
- ] as const;
17
-
18
- function search(from: string, to: string, maxPrice: number) {
19
- const origin = from.trim().toUpperCase();
20
- const destination = to.trim().toUpperCase();
21
- return SAMPLE_FLIGHTS.filter((flight) =>
22
- (!origin || flight.from === origin) &&
23
- (!destination || flight.to === destination) &&
24
- flight.price <= maxPrice
25
- );
26
- }
27
-
28
- const findFlights = defineSharedTool({
29
- description: "Search available flights between two airports",
30
- tool: {
31
- name: "find_flights",
32
- description:
33
- "Search flights between two IATA airport codes. Returns carrier, departure time, duration and price.",
34
- inputSchema: z.object({
35
- from: z.string().describe("Origin IATA code, for example LIS"),
36
- to: z.string().describe("Destination IATA code, for example NBO"),
37
- maxPrice: z.number().default(5_000).describe("Highest acceptable price in USD"),
38
- }),
39
- async do({ from, to, maxPrice }) {
40
- const matches = search(from, to, maxPrice);
41
- if (matches.length === 0) {
42
- return { status: "success" as const, data: { matches: [], note: "No flight matched those constraints." } };
43
- }
44
- return { status: "success" as const, data: { matches } };
45
- },
46
- },
47
- });
48
-
49
- export default findFlights;