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 +24 -5
- package/examples/quickstart/actor.ts +49 -0
- package/package.json +3 -2
- package/src/cli/build.ts +18 -4
- package/src/cli/commands.ts +47 -4
- package/src/cli/dev.ts +14 -1
- package/src/cli/init.ts +65 -0
- package/src/cli/push.ts +16 -3
- package/src/cli/template.ts +62 -0
- package/src/cli/workflow.ts +23 -0
- package/src/server/host.ts +76 -20
- package/ui/assets/index-DUtQpFbK.css +1 -0
- package/ui/assets/index-PaohSk2M.js +21 -0
- package/ui/index.html +2 -2
- package/ui/assets/index-BCrEEGCU.css +0 -1
- package/ui/assets/index-D12zyLjs.js +0 -21
package/README.md
CHANGED
|
@@ -29,27 +29,46 @@ $$\lbrace\mathrm{transitions}\rbrace = f(\mathrm{log})$$
|
|
|
29
29
|
|
|
30
30
|
## Quickstart
|
|
31
31
|
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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")
|
package/src/cli/commands.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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).
|
package/src/cli/init.ts
ADDED
|
@@ -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
|
-
]
|
|
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
|
+
}
|
package/src/server/host.ts
CHANGED
|
@@ -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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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(
|
|
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
|
|