tardie 0.1.0 → 0.2.0-rc.1

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/README.md CHANGED
@@ -29,27 +29,46 @@ $$\lbrace\mathrm{transitions}\rbrace = f(\mathrm{log})$$
29
29
 
30
30
  ## Quickstart
31
31
 
32
- Install Tardigrade and run the default agent. Use Bun 1.4 or later.
32
+ ### For agents
33
+
34
+ Copy this prompt into your coding agent, or install the [Tardigrade skill](skills/tardigrade/SKILL.md):
35
+
36
+ ```text
37
+ Use https://github.com/clavia-labs/tardigrade and follow skills/tardigrade/SKILL.md to create, author, build, push, and run a local actor. Share its Voyager trace URL.
38
+ ```
39
+
40
+ ### For developers
41
+
42
+ Install Tardigrade and initialize an editable template actor. Use Bun 1.4 or later.
33
43
 
34
44
  ```bash
35
45
  bun add -g tardie
46
+ tdg init researcher
47
+ cd researcher
48
+ ```
49
+
50
+ The `init` command creates `researcher/actor.ts` from the bundled template. Read the [Quickstart guide](docs/quickstart.md) to understand the framework, then edit `actor.ts` to describe the agent. Build and push the result into the local actor registry:
51
+
52
+ ```bash
53
+ tdg build actor.ts
54
+ tdg push actor.ts --target local
36
55
  tdg dev
37
56
  ```
38
57
 
39
- Start a thread from another shell:
58
+ Keep `tdg dev` running. Start the actor from another shell in the same directory:
40
59
 
41
60
  ```bash
42
- tdg run "read this repo and tell me what it does"
61
+ tdg run "read this repo and tell me what it does" --actor researcher
43
62
  ```
44
63
 
45
- Watch the live trajectory at [localhost:4242](http://localhost:4242).
64
+ Voyager opens at [localhost:4242](http://localhost:4242) by default. `tdg run` prints the direct Voyager URL for the new trace.
46
65
 
47
66
  <picture>
48
67
  <source media="(prefers-color-scheme: dark)" srcset="docs/assets/voyager-dark.png">
49
68
  <img alt="The voyager: a thread's log, one row per event" src="docs/assets/voyager-light.png">
50
69
  </picture>
51
70
 
52
- ## Build one
71
+ ## Build your own harness
53
72
 
54
73
  ```bash
55
74
  bun add tardie
@@ -0,0 +1,49 @@
1
+ import {
2
+ agentOf,
3
+ agentsPackage,
4
+ budget,
5
+ codeModeFor,
6
+ compaction,
7
+ defineActor,
8
+ fetchPackage,
9
+ filesPackage,
10
+ reply,
11
+ workspacePackage
12
+ } from "tardie"
13
+
14
+ // actorName is the stable name used by build, push, and run.
15
+ const actorName = "researcher"
16
+
17
+ // actorInstructions is the main place to describe the job and its expected answer.
18
+ const actorInstructions = `
19
+ You are ${actorName}, a focused research agent.
20
+
21
+ Investigate the user's request carefully.
22
+ Use project files as evidence.
23
+ Delegate independent research when it helps.
24
+ Return a concise answer with concrete findings.
25
+ `.trim()
26
+
27
+ const instructions = {
28
+ name: "instructions",
29
+ system: actorInstructions
30
+ }
31
+
32
+ export default defineActor({
33
+ name: actorName,
34
+ // agentOf combines small capabilities into the actor's behavior.
35
+ actor: agentOf([
36
+ instructions,
37
+ // codeModeFor gives the model one code tool over the packages listed here.
38
+ codeModeFor({
39
+ // packages grant access to local files, HTTP, child agents, and saved tool results.
40
+ packages: [filesPackage(), fetchPackage(), agentsPackage(), workspacePackage()]
41
+ }),
42
+ // reply returns a finished turn to the actor that delegated it.
43
+ reply,
44
+ // budget stops work tools when the turn reaches its tool-call limit.
45
+ budget,
46
+ // compaction summarizes older context when a long turn outgrows its context window.
47
+ compaction
48
+ ])
49
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tardie",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-rc.1",
4
4
  "license": "MIT",
5
5
  "author": "Clavia, Inc.",
6
6
  "description": "A durable agent harness. State is a pure function of the log.",
@@ -17,7 +17,8 @@
17
17
  },
18
18
  "files": [
19
19
  "src",
20
- "ui"
20
+ "ui",
21
+ "examples"
21
22
  ],
22
23
  "engines": {
23
24
  "bun": ">=1.4.0"
package/src/cli/build.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto"
2
2
  import { mkdtemp, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
3
3
  import { dirname, join, resolve } from "node:path"
4
- import { pathToFileURL } from "node:url"
4
+ import { fileURLToPath, pathToFileURL } from "node:url"
5
5
 
6
6
  import {
7
7
  ACTOR_ARTIFACT_VERSION,
@@ -10,9 +10,12 @@ import {
10
10
  type ActorDefinition
11
11
  } from "tardie"
12
12
 
13
+ import { shellWord } from "./workflow"
14
+
13
15
  export const DEFAULT_BUILD_DIRECTORY = ".tardigrade/build"
14
16
  export const ACTOR_MODULE_FILE = "actor.mjs"
15
17
  export const ACTOR_MANIFEST_FILE = "manifest.json"
18
+ export const TARDIE_ENTRY = fileURLToPath(import.meta.resolve("tardie"))
16
19
 
17
20
  export interface BuildActorOptions {
18
21
  readonly out?: string
@@ -45,6 +48,13 @@ const definitionOf = async (modulePath: string): Promise<ActorDefinition<unknown
45
48
  return candidate as ActorDefinition<unknown>
46
49
  }
47
50
 
51
+ export const tardiePlugin = (entry: string = TARDIE_ENTRY): Bun.BunPlugin => ({
52
+ name: "tardie",
53
+ setup(builder) {
54
+ builder.onResolve({ filter: /^tardie$/ }, () => ({ path: entry }))
55
+ }
56
+ })
57
+
48
58
  export const buildActor = async (entry: string, options: BuildActorOptions = {}): Promise<BuiltActor> => {
49
59
  const cwd = resolve(options.cwd ?? process.cwd())
50
60
  const source = resolve(cwd, entry)
@@ -59,7 +69,8 @@ export const buildActor = async (entry: string, options: BuildActorOptions = {})
59
69
  target: "bun",
60
70
  format: "esm",
61
71
  minify: false,
62
- sourcemap: "none"
72
+ sourcemap: "none",
73
+ plugins: [tardiePlugin()]
63
74
  })
64
75
  if (!result.success) {
65
76
  const detail = result.logs.map((log) => log.message).join("\n")
@@ -99,9 +110,12 @@ export const buildActor = async (entry: string, options: BuildActorOptions = {})
99
110
  }
100
111
  }
101
112
 
102
- export const buildSummary = (built: BuiltActor): string =>
113
+ export const buildSummary = (built: BuiltActor, entry: string): string =>
103
114
  [
104
115
  `built ${built.manifest.name}`,
105
116
  `at ${built.directory}`,
106
- `hash ${built.manifest.digest}`
117
+ `hash ${built.manifest.digest}`,
118
+ "",
119
+ "next",
120
+ ` tdg push ${shellWord(entry)} --target local`
107
121
  ].join("\n")
@@ -6,11 +6,13 @@ import { modelIsConfigured } from "tardie/server/host"
6
6
 
7
7
  import { buildActor, buildSummary, DEFAULT_BUILD_DIRECTORY } from "./build"
8
8
  import { readFileConfig, resolveRemote, resolveServer } from "./config"
9
- import { availableDevPort, DEFAULT_MIN_PORT, DEV_URL_HOST, dev, openBrowser } from "./dev"
9
+ import { availableDevPort, DEFAULT_ACTOR_REFRESH_MILLIS, DEFAULT_MIN_PORT, DEV_URL_HOST, dev, openBrowser } from "./dev"
10
+ import { initActor, initSummary } from "./init"
10
11
  import { DEFAULT_ACTOR_DIRECTORY, pushActor, pushSummary, PUSH_TARGETS } from "./push"
11
12
  import { homeOf, HOME_MISSING, setupJson, setupPrompt, setupSummary, writeSetup } from "./setup"
12
13
  import { actorsTable, threadsTable, DEFAULT_DETAIL_WIDTH, eventsTable, jsonOf, turnLines } from "./render"
13
14
  import { Cli, type CliProjections } from "./services"
15
+ import { traceUrlFor } from "./workflow"
14
16
 
15
17
  // The command tree. Every command is a declaration: its flags, its arguments, and its description
16
18
  // are values, so the help a person reads and the completions a shell installs are generated from
@@ -159,6 +161,36 @@ export const setupCommand = Command.make("setup", { json }, (flags) =>
159
161
  ])
160
162
  )
161
163
 
164
+ export const initCommand = Command.make("init", {
165
+ name: Argument.string("name").pipe(Argument.withDescription("The actor name")),
166
+ dir: Flag.string("dir").pipe(
167
+ Flag.withDescription("The directory to create. Defaults to a directory named after the actor."),
168
+ Flag.optional
169
+ ),
170
+ force: Flag.boolean("force").pipe(
171
+ Flag.withDescription("Replace actor.ts when it already exists."),
172
+ Flag.withDefault(false)
173
+ ),
174
+ json
175
+ }, (flags) =>
176
+ Effect.gen(function*() {
177
+ const directory = stated(flags.dir)
178
+ const initialized = yield* Effect.tryPromise({
179
+ try: () => initActor(flags.name, {
180
+ ...(directory === undefined ? {} : { directory }),
181
+ force: flags.force
182
+ }),
183
+ catch: userErrorOf
184
+ })
185
+ yield* Console.log(flags.json ? jsonOf(initialized) : initSummary(initialized))
186
+ })).pipe(
187
+ Command.withDescription("Create an editable actor from the bundled quickstart."),
188
+ Command.withExamples([
189
+ { command: "tdg init researcher", description: "Create researcher/actor.ts and print the local workflow" },
190
+ { command: "tdg init reviewer --dir actors/reviewer", description: "Create the actor in a stated directory" }
191
+ ])
192
+ )
193
+
162
194
  export const buildCommand = Command.make("build", {
163
195
  entry: Argument.string("entry").pipe(Argument.withDescription("The actor source file to bundle")),
164
196
  out: Flag.string("out").pipe(
@@ -173,7 +205,7 @@ export const buildCommand = Command.make("build", {
173
205
  try: () => buildActor(flags.entry, out === undefined ? {} : { out }),
174
206
  catch: userErrorOf
175
207
  })
176
- yield* Console.log(flags.json ? jsonOf(built) : buildSummary(built))
208
+ yield* Console.log(flags.json ? jsonOf(built) : buildSummary(built, flags.entry))
177
209
  })).pipe(
178
210
  Command.withDescription("Bundle and validate one named actor as a portable artifact."),
179
211
  Command.withExamples([
@@ -246,6 +278,10 @@ export const devCommand = Command.make("dev", {
246
278
  Flag.withDescription("The directory holding pushed actor databases. Defaults to TARDIGRADE_ACTOR_DATA."),
247
279
  Flag.optional
248
280
  ),
281
+ actorRefreshMillis: Flag.integer("actor-refresh-ms").pipe(
282
+ Flag.withDescription("Milliseconds to wait after a local actor change before refreshing the registry."),
283
+ Flag.withDefault(DEFAULT_ACTOR_REFRESH_MILLIS)
284
+ ),
249
285
  ui: Flag.string("ui").pipe(
250
286
  Flag.withDescription("The directory holding the built UI. Defaults to the build shipped beside this command."),
251
287
  Flag.optional
@@ -307,6 +343,7 @@ export const devCommand = Command.make("dev", {
307
343
  try: () => dev({
308
344
  config: config2,
309
345
  assets: stated(flags.ui),
346
+ actorRefreshMillis: flags.actorRefreshMillis,
310
347
  ...(flags.open ? { onListen: openBrowser } : {})
311
348
  }),
312
349
  catch: userErrorOf
@@ -346,7 +383,13 @@ export const runCommand = Command.make("run", {
346
383
  const id = stated(flags.id) ?? cli.mintId()
347
384
  const accepted = yield* call(() => client.append(thread, { type: "MessageReceived", id, text: flags.brief }))
348
385
  const view = yield* settle(client, accepted.thread, id, flags.poll, flags.timeout)
349
- yield* Console.log(flags.json ? jsonOf(view) : turnLines(accepted.thread, view))
386
+ yield* Console.log(
387
+ flags.json
388
+ ? jsonOf(view)
389
+ : view.status === SETTLED
390
+ ? `${turnLines(accepted.thread, view)}\n\ntrace\n ${traceUrlFor(client.baseUrl, client.actor, accepted.thread)}`
391
+ : turnLines(accepted.thread, view)
392
+ )
350
393
  if (view.status !== SETTLED) {
351
394
  return yield* Effect.fail(userErrorOf(`turn ${view.turn} on thread ${accepted.thread} is ${view.status}`))
352
395
  }
@@ -444,5 +487,5 @@ export const tdg = Command.make("tdg").pipe(
444
487
  Command.withDescription(
445
488
  "The tardigrade command. Every read is a projection of a durable log, and every failure is the server's own problem document."
446
489
  ),
447
- Command.withSubcommands([setupCommand, buildCommand, pushCommand, devCommand, actorsCommand, runCommand, sendCommand, lsCommand, eventsCommand])
490
+ Command.withSubcommands([setupCommand, initCommand, buildCommand, pushCommand, devCommand, actorsCommand, runCommand, sendCommand, lsCommand, eventsCommand])
448
491
  )
package/src/cli/dev.ts CHANGED
@@ -26,6 +26,10 @@ export const DEV_URL_HOST = "localhost"
26
26
  // occupied. The `--min-port` flag lets a caller narrow this range.
27
27
  export const DEFAULT_MIN_PORT = 1024
28
28
 
29
+ // DEFAULT_ACTOR_REFRESH_MILLIS lets an atomic local push finish its directory swaps before tdg dev
30
+ // reconciles the actor root. DevOptions and --actor-refresh-ms can replace it.
31
+ export const DEFAULT_ACTOR_REFRESH_MILLIS = 50
32
+
29
33
  // The status that means the router matched nothing. It is the seam the UI is served through: the
30
34
  // declared routes answer first, and only a path none of them owns reaches the build.
31
35
  export const UNMATCHED = 404
@@ -110,6 +114,8 @@ export interface DevOptions {
110
114
  readonly assets?: string | undefined
111
115
  // The model seam, which a test binds to a scripted mind (apps/server/src/host.ts, ThreadsOptions).
112
116
  readonly threads?: ThreadsOptions | undefined
117
+ // actorRefreshMillis is the visible debounce applied to local actor-root changes.
118
+ readonly actorRefreshMillis?: number | undefined
113
119
  readonly disableLogger?: boolean | undefined
114
120
  readonly disableListenLog?: boolean | undefined
115
121
  // onListen receives the UI URL after the server owns its listening socket.
@@ -120,9 +126,16 @@ export interface DevOptions {
120
126
  // Layer rather than a running process, so the caller owns the scope and the process that stops
121
127
  // listening stops writing (apps/server/src/host.ts, layerThreads).
122
128
  export const dev = (options: DevOptions) => {
129
+ const actorRefreshMillis = options.actorRefreshMillis ?? DEFAULT_ACTOR_REFRESH_MILLIS
130
+ if (!Number.isInteger(actorRefreshMillis) || actorRefreshMillis < 0) {
131
+ throw new Error(`actor refresh must be a non-negative integer, got ${actorRefreshMillis}`)
132
+ }
123
133
  const root = resolveAssets(options.assets)
124
134
  const config = layerConfig(options.config)
125
- const threads = Layer.provide(layerThreads(options.threads ?? {}), config)
135
+ const threads = Layer.provide(layerThreads({
136
+ ...options.threads,
137
+ actorRefresh: { debounceMillis: actorRefreshMillis }
138
+ }), config)
126
139
  // provideMerge rather than provide: the listening server stays visible in the layer's own
127
140
  // services, which is what lets a caller read the address it was given when it asked for port 0
128
141
  // (dev.test.ts).
@@ -0,0 +1,65 @@
1
+ import { mkdir, writeFile } from "node:fs/promises"
2
+ import { dirname, relative, resolve } from "node:path"
3
+
4
+ import { actorTemplate } from "./template"
5
+ import { runCommandFor, shellWord } from "./workflow"
6
+
7
+ export const DEFAULT_ACTOR_ENTRY = "actor.ts"
8
+
9
+ export interface InitActorOptions {
10
+ readonly cwd?: string
11
+ readonly directory?: string
12
+ readonly force?: boolean
13
+ }
14
+
15
+ export interface InitializedActor {
16
+ readonly name: string
17
+ readonly directory: string
18
+ readonly entry: string
19
+ }
20
+
21
+ export const defaultInitDirectory = (name: string): string => name
22
+
23
+ const existsError = (error: unknown): boolean =>
24
+ typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST"
25
+
26
+ export const initActor = async (name: string, options: InitActorOptions = {}): Promise<InitializedActor> => {
27
+ const cwd = options.cwd ?? process.cwd()
28
+ const directory = resolve(cwd, options.directory ?? defaultInitDirectory(name))
29
+ const entry = resolve(directory, DEFAULT_ACTOR_ENTRY)
30
+ const source = await actorTemplate({ name })
31
+
32
+ await mkdir(dirname(entry), { recursive: true })
33
+ try {
34
+ await writeFile(entry, source, { encoding: "utf8", flag: options.force === true ? "w" : "wx" })
35
+ } catch (error) {
36
+ if (existsError(error)) {
37
+ throw new Error(`actor already exists at ${entry}. Choose another directory or pass --force.`)
38
+ }
39
+ throw error
40
+ }
41
+
42
+ return { name, directory, entry }
43
+ }
44
+
45
+ const shownPath = (cwd: string, path: string): string => {
46
+ const shown = relative(cwd, path)
47
+ return shown.length === 0 ? "." : shown
48
+ }
49
+
50
+ export const initSummary = (actor: InitializedActor, cwd: string = process.cwd()): string => {
51
+ const directory = shownPath(cwd, actor.directory)
52
+ const entry = shownPath(actor.directory, actor.entry)
53
+ return [
54
+ `created ${shownPath(cwd, actor.entry)}`,
55
+ "",
56
+ "next",
57
+ ` cd ${shellWord(directory)}`,
58
+ ` tdg build ${shellWord(entry)}`,
59
+ ` tdg push ${shellWord(entry)} --target local`,
60
+ " tdg dev",
61
+ "",
62
+ "then, in another terminal",
63
+ ` ${runCommandFor(actor.name)}`
64
+ ].join("\n")
65
+ }
package/src/cli/push.ts CHANGED
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path"
5
5
  import type { ActorArtifactManifest } from "tardie"
6
6
 
7
7
  import { ACTOR_MANIFEST_FILE, ACTOR_MODULE_FILE, buildActor, type BuildActorOptions, type BuiltActor } from "./build"
8
+ import { runCommandFor } from "./workflow"
8
9
 
9
10
  export const DEFAULT_ACTOR_DIRECTORY = ".tardigrade/actors"
10
11
  export const PUSH_PATH = "/v1/actors"
@@ -98,10 +99,22 @@ export const pushActor = async (entry: string, options: PushActorOptions): Promi
98
99
  return options.target === "local" ? pushLocal(built, options) : pushHosted(built, options)
99
100
  }
100
101
 
101
- export const pushSummary = (pushed: PushedActor): string =>
102
- [
102
+ export const pushSummary = (pushed: PushedActor): string => {
103
+ const summary = [
103
104
  `pushed ${pushed.manifest.name}`,
104
105
  `to ${pushed.target}`,
105
106
  `at ${pushed.location}`,
106
107
  `hash ${pushed.manifest.digest}`
107
- ].join("\n")
108
+ ]
109
+ if (pushed.target === "local") {
110
+ summary.push(
111
+ "",
112
+ "next",
113
+ " tdg dev",
114
+ "",
115
+ "then, in another terminal",
116
+ ` ${runCommandFor(pushed.manifest.name)}`
117
+ )
118
+ }
119
+ return summary.join("\n")
120
+ }
@@ -0,0 +1,62 @@
1
+ import { ACTOR_NAME_PATTERN } from "tardie"
2
+ import { existsSync } from "node:fs"
3
+ import { readFile } from "node:fs/promises"
4
+ import { fileURLToPath } from "node:url"
5
+
6
+ export interface ActorTemplateOptions {
7
+ readonly name: string
8
+ readonly instructions?: string
9
+ }
10
+
11
+ export const INSTALLED_ACTOR_TEMPLATE = "../../examples/quickstart/actor.ts"
12
+ export const REPO_ACTOR_TEMPLATE = "../../../examples/quickstart/actor.ts"
13
+ export const ACTOR_TEMPLATE_CANDIDATES: ReadonlyArray<string> = [INSTALLED_ACTOR_TEMPLATE, REPO_ACTOR_TEMPLATE]
14
+
15
+ const templateLiteralOf = (value: string): string =>
16
+ value
17
+ .replaceAll("\\", "\\\\")
18
+ .replaceAll("`", "\\`")
19
+ .replaceAll("${", "\\${")
20
+
21
+ const replaceExactlyOnce = (source: string, pattern: RegExp, replacement: string, field: string): string => {
22
+ const matches = source.match(pattern)
23
+ if (matches === null || matches.length !== 1) throw new Error(`quickstart template must contain one ${field}`)
24
+ return source.replace(pattern, replacement)
25
+ }
26
+
27
+ export const renderActorTemplate = (source: string, options: ActorTemplateOptions): string => {
28
+ if (!ACTOR_NAME_PATTERN.test(options.name)) {
29
+ throw new Error(`actor name must match ${String(ACTOR_NAME_PATTERN)}, got ${JSON.stringify(options.name)}`)
30
+ }
31
+
32
+ let rendered = replaceExactlyOnce(
33
+ source,
34
+ /^const actorName = .+$/gmu,
35
+ `const actorName = ${JSON.stringify(options.name)}`,
36
+ "actorName declaration"
37
+ )
38
+ if (options.instructions === undefined) return rendered
39
+
40
+ const instructions = options.instructions.trim()
41
+ if (instructions.length === 0) throw new Error("actor instructions must not be blank")
42
+ rendered = replaceExactlyOnce(
43
+ rendered,
44
+ /^const actorInstructions = `[\s\S]*?`\.trim\(\)$/gmu,
45
+ `const actorInstructions = \`\n${templateLiteralOf(instructions)}\n\`.trim()`,
46
+ "actorInstructions declaration"
47
+ )
48
+ return rendered
49
+ }
50
+
51
+ export const loadActorTemplate = async (candidates: ReadonlyArray<string>): Promise<string> => {
52
+ const found = candidates.find((candidate) => existsSync(candidate))
53
+ if (found === undefined) throw new Error(`quickstart template is missing. Looked in: ${candidates.join(", ")}`)
54
+ return readFile(found, "utf8")
55
+ }
56
+
57
+ export const actorTemplate = async (
58
+ options: ActorTemplateOptions,
59
+ candidates: ReadonlyArray<string> = ACTOR_TEMPLATE_CANDIDATES.map((candidate) =>
60
+ fileURLToPath(new URL(candidate, import.meta.url))
61
+ )
62
+ ): Promise<string> => renderActorTemplate(await loadActorTemplate(candidates), options)
@@ -0,0 +1,23 @@
1
+ // DEFAULT_ONBOARDING_BRIEF is the first local run suggested after an actor is pushed.
2
+ export const DEFAULT_ONBOARDING_BRIEF = "Read this repository and tell me what it does"
3
+
4
+ // shellWord quotes a generated shell argument when spaces or shell punctuation make a bare word unsafe (workflow.test.ts).
5
+ export const shellWord = (value: string): string =>
6
+ /^[A-Za-z0-9_./-]+$/u.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`
7
+
8
+ // runCommandFor renders the local quickstart command with its actor and brief stated.
9
+ export const runCommandFor = (
10
+ actor: string,
11
+ brief: string = DEFAULT_ONBOARDING_BRIEF
12
+ ): string => `tdg run ${shellWord(brief)} --actor ${shellWord(actor)}`
13
+
14
+ // traceUrlFor selects the actor and thread in the Voyager served at the API origin (apps/voyager/src/nav.ts, Route).
15
+ export const traceUrlFor = (baseUrl: string, actor: string, thread: string): string => {
16
+ const url = new URL(baseUrl)
17
+ url.pathname = "/"
18
+ url.search = ""
19
+ url.hash = ""
20
+ url.searchParams.set("actor", actor)
21
+ url.searchParams.set("thread", thread)
22
+ return url.toString()
23
+ }
@@ -2,6 +2,7 @@ import { Clock, Context, Effect, Layer } from "effect"
2
2
  import { FetchHttpClient } from "effect/unstable/http"
3
3
  import { BunFileSystem, BunPath } from "@effect/platform-bun"
4
4
  import { createHash } from "node:crypto"
5
+ import { watch, type FSWatcher } from "node:fs"
5
6
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"
6
7
  import { join, resolve } from "node:path"
7
8
  import { pathToFileURL } from "node:url"
@@ -102,6 +103,12 @@ export interface ThreadsOptions {
102
103
  // derivation whole, which is how a test runs a scripted mind with no credentials
103
104
  // (host.test.ts). It is the one seam because Infer is the one place a turn leaves the process.
104
105
  readonly infer?: Layer.Layer<Infer>
106
+ // actorRefresh watches the actor root and reconciles its artifacts after the stated debounce.
107
+ // Absent keeps a hosted server's registry fixed except for PUT /v1/actors; tdg dev supplies it.
108
+ readonly actorRefresh?: {
109
+ readonly debounceMillis: number
110
+ readonly onError?: ((error: Error) => void) | undefined
111
+ } | undefined
105
112
  }
106
113
 
107
114
  interface ActorRuntime {
@@ -240,41 +247,91 @@ const make = (options: ThreadsOptions) =>
240
247
  const lane = layerLane(config, options)
241
248
  const runtimes = new Map<string, ActorRuntime>()
242
249
  const builtIn = assemblyOf()
250
+ const root = resolve(config.actors)
251
+ let mutations: Promise<void> = Promise.resolve()
252
+ const exclusive = <A>(operation: () => Promise<A>): Promise<A> => {
253
+ const result = mutations.then(operation, operation)
254
+ mutations = result.then(() => undefined, () => undefined)
255
+ return result
256
+ }
243
257
  const open = async (summary: ActorSummary, actor: Actor<ServerR>, log: string): Promise<ActorRuntime> => {
244
258
  const runtime = await runtimeOf(summary, actor, log, lane)
245
259
  runtimes.set(summary.name, runtime)
246
260
  return runtime
247
261
  }
262
+ const load = async (directory: string): Promise<{ readonly summary: ActorSummary; readonly actor: Actor<ServerR> }> => {
263
+ const artifact = await manifestOf(directory)
264
+ if (artifact.manifest.name === RESERVED_ACTOR) throw new Error(`${RESERVED_ACTOR} is reserved for the built-in actor`)
265
+ const definition = await definitionOf(join(directory, artifact.manifest.module), artifact.manifest)
266
+ return {
267
+ summary: { name: definition.name, builtIn: false, digest: artifact.manifest.digest },
268
+ actor: definition.actor
269
+ }
270
+ }
271
+ const replace = async (summary: ActorSummary, actor: Actor<ServerR>): Promise<void> => {
272
+ const current = runtimes.get(summary.name)
273
+ if (current?.summary.digest === summary.digest) return
274
+ if (current !== undefined) {
275
+ await current.close()
276
+ runtimes.delete(summary.name)
277
+ }
278
+ await open(summary, actor, join(resolve(config.actorData), `${summary.name}.sqlite`))
279
+ }
280
+ const synchronize = async (): Promise<void> => {
281
+ const entries = await readdir(root, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
282
+ if (error.code === "ENOENT") return []
283
+ throw error
284
+ })
285
+ const found = new Set<string>()
286
+ for (const entry of entries) {
287
+ if (!entry.isDirectory() || !ACTOR_NAME_PATTERN.test(entry.name)) continue
288
+ const loaded = await load(join(root, entry.name))
289
+ if (loaded.summary.name !== entry.name) throw new Error(`actor artifact name does not match directory ${JSON.stringify(entry.name)}`)
290
+ found.add(loaded.summary.name)
291
+ await replace(loaded.summary, loaded.actor)
292
+ }
293
+ for (const [name, runtime] of runtimes) {
294
+ if (name === RESERVED_ACTOR || found.has(name)) continue
295
+ await runtime.close()
296
+ runtimes.delete(name)
297
+ }
298
+ }
299
+ let watcher: FSWatcher | undefined
300
+ let refreshTimer: ReturnType<typeof setTimeout> | undefined
248
301
  yield* Effect.acquireRelease(
249
302
  Effect.promise(async () => {
250
303
  await open({ name: RESERVED_ACTOR, builtIn: true }, builtIn, config.db)
251
- const root = resolve(config.actors)
252
- const entries = await readdir(root, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
253
- if (error.code === "ENOENT") return []
254
- throw error
255
- })
256
- for (const entry of entries) {
257
- if (!entry.isDirectory()) continue
258
- const directory = join(root, entry.name)
259
- const artifact = await manifestOf(directory)
260
- if (artifact.manifest.name === RESERVED_ACTOR) throw new Error(`${RESERVED_ACTOR} is reserved for the built-in actor`)
261
- const definition = await definitionOf(join(directory, artifact.manifest.module), artifact.manifest)
262
- await open(
263
- { name: definition.name, builtIn: false, digest: artifact.manifest.digest },
264
- definition.actor,
265
- join(resolve(config.actorData), `${definition.name}.sqlite`)
266
- )
304
+ await synchronize()
305
+ if (options.actorRefresh !== undefined) {
306
+ const { debounceMillis } = options.actorRefresh
307
+ if (!Number.isInteger(debounceMillis) || debounceMillis < 0) {
308
+ throw new Error(`actor refresh debounce must be a non-negative integer, got ${debounceMillis}`)
309
+ }
310
+ await mkdir(root, { recursive: true })
311
+ const report = options.actorRefresh.onError ?? ((error: Error) => console.error(`actor refresh failed: ${error.message}`))
312
+ watcher = watch(root, () => {
313
+ if (refreshTimer !== undefined) clearTimeout(refreshTimer)
314
+ refreshTimer = setTimeout(() => {
315
+ refreshTimer = undefined
316
+ void exclusive(synchronize).catch((error: unknown) => report(error instanceof Error ? error : new Error(String(error))))
317
+ }, debounceMillis)
318
+ })
267
319
  }
268
320
  return runtimes
269
321
  }),
270
- (opened) => Effect.promise(() => Promise.all([...opened.values()].map((runtime) => runtime.close())).then(() => undefined))
322
+ (opened) => Effect.promise(async () => {
323
+ watcher?.close()
324
+ if (refreshTimer !== undefined) clearTimeout(refreshTimer)
325
+ await mutations
326
+ await Promise.all([...opened.values()].map((runtime) => runtime.close()))
327
+ })
271
328
  )
272
329
 
273
330
  const selected = (name: string): ActorThreads | undefined => runtimes.get(name)?.threads
274
331
  const primary = selected(RESERVED_ACTOR)!
275
332
  const push = (artifact: ActorArtifact): Effect.Effect<ActorSummary, Error> =>
276
333
  Effect.tryPromise({
277
- try: async () => {
334
+ try: () => exclusive(async () => {
278
335
  const manifest = artifact.manifest as ActorArtifactManifest
279
336
  if (manifest.schema !== ACTOR_ARTIFACT_VERSION) throw new Error(`unsupported actor artifact schema ${manifest.schema}`)
280
337
  if (!ACTOR_NAME_PATTERN.test(manifest.name)) throw new Error(`actor name must match ${String(ACTOR_NAME_PATTERN)}`)
@@ -282,7 +339,6 @@ const make = (options: ThreadsOptions) =>
282
339
  if (manifest.module !== "actor.mjs") throw new Error(`actor module must be ${JSON.stringify("actor.mjs")}`)
283
340
  const actual = digestOf(artifact.module)
284
341
  if (actual !== manifest.digest) throw new Error(`actor artifact digest mismatch: expected ${manifest.digest}, got ${actual}`)
285
- const root = resolve(config.actors)
286
342
  const destination = join(root, manifest.name)
287
343
  const temporary = `${destination}.incoming`
288
344
  const previous = `${destination}.previous`
@@ -313,7 +369,7 @@ const make = (options: ThreadsOptions) =>
313
369
  await rm(temporary, { recursive: true, force: true })
314
370
  throw error
315
371
  }
316
- },
372
+ }),
317
373
  catch: (error) => error instanceof Error ? error : new Error(String(error))
318
374
  })
319
375