@agentproto/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/config.d.ts +144 -0
- package/dist/config.mjs +76 -0
- package/dist/config.mjs.map +1 -0
- package/dist/conversations.d.ts +60 -0
- package/dist/conversations.mjs +146 -0
- package/dist/conversations.mjs.map +1 -0
- package/dist/heartbeat-COGpMrJS.d.ts +120 -0
- package/dist/heartbeat.d.ts +2 -0
- package/dist/heartbeat.mjs +185 -0
- package/dist/heartbeat.mjs.map +1 -0
- package/dist/index.d.ts +546 -0
- package/dist/index.mjs +4350 -0
- package/dist/index.mjs.map +1 -0
- package/dist/mcp-imports.d.ts +117 -0
- package/dist/mcp-imports.mjs +82 -0
- package/dist/mcp-imports.mjs.map +1 -0
- package/dist/resume-strategies.d.ts +66 -0
- package/dist/resume-strategies.mjs +64 -0
- package/dist/resume-strategies.mjs.map +1 -0
- package/dist/workspace-fs.d.ts +26 -0
- package/dist/workspace-fs.mjs +60 -0
- package/dist/workspace-fs.mjs.map +1 -0
- package/dist/workspaces-config.d.ts +81 -0
- package/dist/workspaces-config.mjs +136 -0
- package/dist/workspaces-config.mjs.map +1 -0
- package/package.json +103 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jeremy André and agentproto contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @agentproto/runtime
|
|
2
|
+
|
|
3
|
+
The long-running gateway that turns an agentproto workspace into a live runtime. Composes the MCP server (CRUD verbs), HTTP transport, HEARTBEAT.md autonomy loop, conversation persistence, and the **sessions registry** (agent CLIs, raw spawns, and real PTY-backed terminals).
|
|
4
|
+
|
|
5
|
+
Used directly by [`@agentproto/cli`](../cli/README.md)'s `agentproto serve` verb. Also embeddable when you want to host the same surface inside another Node process (the playground gateway, app-specific deployments, etc.).
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @agentproto/runtime
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
If you just want a daemon, use the CLI — `agentproto serve` wires this package end-to-end with adapter resolution, PTY support, and tunnel reconnect logic. The docs below cover the **embedding** path.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createGateway } from "@agentproto/runtime"
|
|
17
|
+
import { loadNodePtyFactory } from "@agentproto/cli/util/pty-factory" // optional
|
|
18
|
+
|
|
19
|
+
const gateway = await createGateway({
|
|
20
|
+
workspace: "/abs/path/to/workspace",
|
|
21
|
+
specs: [], // AIP doctype specs (optional)
|
|
22
|
+
port: 18790,
|
|
23
|
+
// Optional: enable POST /sessions/terminal + WS /sessions/:id/pty
|
|
24
|
+
spawnPty: await loadNodePtyFactory() ?? undefined,
|
|
25
|
+
// Optional: enable POST /sessions/agent + start_agent_session MCP tool
|
|
26
|
+
resolveAgentAdapter: async slug => { /* return AgentAdapter or null */ },
|
|
27
|
+
listAgentAdapters: async () => [ /* AdapterInfo[] */ ],
|
|
28
|
+
})
|
|
29
|
+
console.log("gateway up at", gateway.url)
|
|
30
|
+
// ... later
|
|
31
|
+
await gateway.stop()
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A per-boot bearer token is generated automatically and written into `<workspace>/.agentproto/runtime.json` (mode `0600`). Override with `createGateway({ token })` if you have your own.
|
|
35
|
+
|
|
36
|
+
## What the gateway exposes
|
|
37
|
+
|
|
38
|
+
| Surface | URL | Notes |
|
|
39
|
+
|-------------------|------------------------------------------|--------------------------------------------------------|
|
|
40
|
+
| Health | `GET /health` | Workspace + uptime — always public |
|
|
41
|
+
| Events (SSE) | `GET /events` | RuntimeEvents stream |
|
|
42
|
+
| MCP | `POST /mcp` (Streamable HTTP) | Stateless mode; per-request transport |
|
|
43
|
+
| Conversations | `GET /conversations` / `GET /conversations/<id>` | Markdown bodies |
|
|
44
|
+
| Adapter discovery | `GET /adapters` | When `listAgentAdapters` is wired |
|
|
45
|
+
| Sessions list | `GET /sessions` / `GET /sessions/:id` | id-or-name in `:id` |
|
|
46
|
+
| Agent spawn | `POST /sessions/agent` | Long-lived ACP agent (needs `resolveAgentAdapter`) |
|
|
47
|
+
| **PTY spawn** | **`POST /sessions/terminal`** | Needs `spawnPty` factory |
|
|
48
|
+
| **PTY attach** | **`WS /sessions/:id/pty`** | JSON frames `{kind:data|input|resize|exit|ping|pong}`; multi-subscriber, min-size resize, ring-buffer replay |
|
|
49
|
+
| SSE attach | `GET /sessions/:id/stream` | Line-by-line text events |
|
|
50
|
+
| Kill / forget | `POST /sessions/:id/kill`, `DELETE /sessions/:id` | SIGTERM, then drop from registry |
|
|
51
|
+
|
|
52
|
+
### Auth model
|
|
53
|
+
|
|
54
|
+
- `Authorization: Bearer <token>` required on **mutating** `/sessions/*` routes (POST/DELETE) and the PTY WS upgrade.
|
|
55
|
+
- **No loopback bypass** for those routes — the threat being defended against is a browser fetch from a localhost-loaded page, which IS loopback. A browser can't read `runtime.json` (mode 0600); a same-user process can.
|
|
56
|
+
- Read routes (`GET /sessions`, SSE `/stream`) stay open for read-only telemetry compatibility.
|
|
57
|
+
- The optional `auth?: AuthOptions` field on `createGateway` is for the *tunnel* bearer (Cloudflare-fronted public surface), independent of the per-boot token.
|
|
58
|
+
|
|
59
|
+
## SessionsRegistry
|
|
60
|
+
|
|
61
|
+
Exposed via `gateway.sessions`. Useful when you want to register externally-spawned children (e.g. tunnel-driven spawns) or programmatically attach without going through HTTP.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
gateway.sessions.spawnPty({
|
|
65
|
+
argv: ["bash", "-l"],
|
|
66
|
+
cwd: gateway.workspace,
|
|
67
|
+
workspaceSlug: "default",
|
|
68
|
+
cols: 120,
|
|
69
|
+
rows: 40,
|
|
70
|
+
name: "ops-shell",
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const handle = gateway.sessions.attachPty(
|
|
74
|
+
"ops-shell",
|
|
75
|
+
{ cols: 120, rows: 40 },
|
|
76
|
+
(chunk) => process.stdout.write(chunk),
|
|
77
|
+
(evt) => console.log("exited", evt.exitCode),
|
|
78
|
+
)
|
|
79
|
+
handle?.write("uptime\n")
|
|
80
|
+
handle?.resize(80, 24)
|
|
81
|
+
handle?.detach()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Other methods: `spawn` (raw `child_process.spawn`), `spawnAgent` (ACP), `register` (adopt an external `ChildProcess`), `attach` (SSE-style line subscription), `kill`, `forget`, `findByIdOrName`, `writeTerminalInput`, `readTerminalOutput`, `shutdown`. See [`sessions.ts`](./src/sessions.ts) for the typed surface.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT — see [LICENSE](../../LICENSE).
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `~/.agentproto/config.json` — single hand-editable JSON for the
|
|
3
|
+
* agentproto control plane's defaults. Sits alongside the existing
|
|
4
|
+
* surface files (workspaces.json, credentials.json, sessions.json):
|
|
5
|
+
*
|
|
6
|
+
* workspaces.json which directories are workspaces + which is active
|
|
7
|
+
* credentials.json tunnel host bearer tokens (mode 0600)
|
|
8
|
+
* sessions.json last-known snapshot of the registry (informational)
|
|
9
|
+
* config.json daemon defaults: port, bind, allowed origins,
|
|
10
|
+
* tunnel host, feature toggles
|
|
11
|
+
*
|
|
12
|
+
* Resolution order for every daemon knob is:
|
|
13
|
+
* 1. CLI flag (e.g. --port)
|
|
14
|
+
* 2. Env var (where one exists, e.g. AGENTPROTO_TOKEN)
|
|
15
|
+
* 3. config.json
|
|
16
|
+
* 4. Hardcoded default
|
|
17
|
+
*
|
|
18
|
+
* This means a user can call `agentproto config set daemon.port 18791`
|
|
19
|
+
* once and never re-pass `--port 18791` to `serve install` etc. CLI
|
|
20
|
+
* flags still win for one-off overrides.
|
|
21
|
+
*
|
|
22
|
+
* Schema is intentionally narrow + extensible — unknown keys are
|
|
23
|
+
* preserved on save (deep-merge), so a newer CLI writing a new
|
|
24
|
+
* field won't drop one an older CLI doesn't know about. No secrets
|
|
25
|
+
* here; credentials stay in credentials.json (mode 0600).
|
|
26
|
+
*/
|
|
27
|
+
declare const CONFIG_VERSION: 1;
|
|
28
|
+
interface DaemonConfig {
|
|
29
|
+
/** Absolute path to the workspace the daemon binds to at boot. */
|
|
30
|
+
workspace?: string;
|
|
31
|
+
/** HTTP port. Default 18790. */
|
|
32
|
+
port?: number;
|
|
33
|
+
/** Bind addr. Default 127.0.0.1. */
|
|
34
|
+
bind?: string;
|
|
35
|
+
/** Trusted browser origins for mutating /sessions/* routes (in
|
|
36
|
+
* addition to the hardcoded localhost defaults). */
|
|
37
|
+
allowedOrigins?: string[];
|
|
38
|
+
/** When true, the daemon does NOT auto-trust localhost-on-any-port.
|
|
39
|
+
* Only origins explicitly listed in `allowedOrigins` are allowed.
|
|
40
|
+
* Pair with a curated list (e.g. `["http://localhost:3000"]`) for
|
|
41
|
+
* hardened setups. Default false. */
|
|
42
|
+
strictOrigins?: boolean;
|
|
43
|
+
/** Server label sent in tunnel hello frames. */
|
|
44
|
+
label?: string;
|
|
45
|
+
}
|
|
46
|
+
interface TunnelConfig {
|
|
47
|
+
/** Cloud WS URL. When set + autoconnect=true, `agentproto serve`
|
|
48
|
+
* bootstraps with `--connect <host>`. */
|
|
49
|
+
host?: string;
|
|
50
|
+
/** apt_ daemon token to present at the tunnel upgrade. When set,
|
|
51
|
+
* `agentproto serve` uses this BEFORE falling back to
|
|
52
|
+
* credentials.json — handy in profiles where the token-per-host
|
|
53
|
+
* mapping in credentials.json doesn't fit (e.g. host = tunnel URL
|
|
54
|
+
* but credentials were minted against the api URL). */
|
|
55
|
+
token?: string;
|
|
56
|
+
/** Whether `agentproto daemon start` connects the tunnel by
|
|
57
|
+
* default. v0 only — implementer can ignore until daemon needs it. */
|
|
58
|
+
autoconnect?: boolean;
|
|
59
|
+
}
|
|
60
|
+
interface FeaturesConfig {
|
|
61
|
+
/** Hint that PTY is desired — informational; the daemon still
|
|
62
|
+
* detects node-pty's presence at runtime. */
|
|
63
|
+
pty?: boolean;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Per-environment connection bundle. A profile overrides specific
|
|
67
|
+
* fields of the top-level `daemon` / `tunnel` / `features` blocks
|
|
68
|
+
* when selected via `--profile <name>` (or the top-level
|
|
69
|
+
* `activeProfile` setting). Missing fields fall through to the
|
|
70
|
+
* top-level config, so a profile only needs to declare what's
|
|
71
|
+
* different — typically just `tunnel.host` + `tunnel.token`.
|
|
72
|
+
*
|
|
73
|
+
* Example:
|
|
74
|
+
* {
|
|
75
|
+
* "daemon": { "workspace": "/code", "port": 18790 },
|
|
76
|
+
* "activeProfile": "local",
|
|
77
|
+
* "profiles": {
|
|
78
|
+
* "local": { "tunnel": { "host": "ws://localhost:3200/connect",
|
|
79
|
+
* "token": "apt_local", "autoconnect": true } },
|
|
80
|
+
* "prod": { "tunnel": { "host": "wss://tunnel.guilde.work/connect",
|
|
81
|
+
* "token": "apt_prod", "autoconnect": true },
|
|
82
|
+
* "daemon": { "port": 18791 } }
|
|
83
|
+
* }
|
|
84
|
+
* }
|
|
85
|
+
*
|
|
86
|
+
* Sandbox daemons generate per-sandbox profile entries at provision
|
|
87
|
+
* time so the daemon inside the sandbox boots with
|
|
88
|
+
* `agentproto serve --profile sandbox-<id>` and no extra plumbing.
|
|
89
|
+
*/
|
|
90
|
+
interface ProfileConfig {
|
|
91
|
+
daemon?: DaemonConfig;
|
|
92
|
+
tunnel?: TunnelConfig;
|
|
93
|
+
features?: FeaturesConfig;
|
|
94
|
+
}
|
|
95
|
+
interface AgentprotoConfig {
|
|
96
|
+
version?: number;
|
|
97
|
+
daemon?: DaemonConfig;
|
|
98
|
+
tunnel?: TunnelConfig;
|
|
99
|
+
features?: FeaturesConfig;
|
|
100
|
+
/** Named connection profiles. See `ProfileConfig` for the merge
|
|
101
|
+
* semantics — a profile's fields shallow-override the top-level
|
|
102
|
+
* defaults for the selected run. */
|
|
103
|
+
profiles?: Record<string, ProfileConfig>;
|
|
104
|
+
/** Profile name to use when `--profile` isn't passed. When unset,
|
|
105
|
+
* the top-level `daemon` / `tunnel` blocks are used directly. */
|
|
106
|
+
activeProfile?: string;
|
|
107
|
+
/** Unknown keys preserved across save round-trips. */
|
|
108
|
+
[unknown: string]: unknown;
|
|
109
|
+
}
|
|
110
|
+
declare const CONFIG_FILE_PATH: () => string;
|
|
111
|
+
/**
|
|
112
|
+
* Load config.json. Returns an empty object (NOT null) when the file
|
|
113
|
+
* is missing, malformed, or unreadable — callers can `cfg.daemon?.port`
|
|
114
|
+
* safely without null-guards. Errors during a malformed-read are
|
|
115
|
+
* logged once so the user notices the file is broken without the
|
|
116
|
+
* daemon refusing to boot.
|
|
117
|
+
*/
|
|
118
|
+
declare function loadConfig(path?: string): Promise<AgentprotoConfig>;
|
|
119
|
+
/**
|
|
120
|
+
* Write config.json atomically (tmp + rename) so a concurrent
|
|
121
|
+
* `agentproto config edit` can't half-truncate the file. Writes
|
|
122
|
+
* `next` AS-IS — callers are expected to pass the full desired
|
|
123
|
+
* state (loaded the existing config, mutated, passed it back).
|
|
124
|
+
*
|
|
125
|
+
* Earlier versions deep-merged with the on-disk file, but that made
|
|
126
|
+
* deletions impossible: `setConfigKey(cfg, "x", undefined)` would
|
|
127
|
+
* remove the key from memory, then the deep-merge would silently
|
|
128
|
+
* re-add it from disk. The current design trusts the caller's
|
|
129
|
+
* snapshot and uses atomic rename for crash safety.
|
|
130
|
+
*/
|
|
131
|
+
declare function saveConfig(next: AgentprotoConfig, path?: string): Promise<void>;
|
|
132
|
+
/**
|
|
133
|
+
* Read a dot-notation key (`daemon.port`) out of a config. Returns
|
|
134
|
+
* `undefined` when any segment is missing.
|
|
135
|
+
*/
|
|
136
|
+
declare function getConfigKey(cfg: AgentprotoConfig, dotted: string): unknown;
|
|
137
|
+
/**
|
|
138
|
+
* Set a dot-notation key in a config. Returns a new object — does
|
|
139
|
+
* NOT mutate. Creates intermediate objects as needed. Setting
|
|
140
|
+
* `value: undefined` is treated as a delete.
|
|
141
|
+
*/
|
|
142
|
+
declare function setConfigKey(cfg: AgentprotoConfig, dotted: string, value: unknown): AgentprotoConfig;
|
|
143
|
+
|
|
144
|
+
export { type AgentprotoConfig, CONFIG_FILE_PATH, CONFIG_VERSION, type DaemonConfig, type FeaturesConfig, type ProfileConfig, type TunnelConfig, getConfigKey, loadConfig, saveConfig, setConfigKey };
|
package/dist/config.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { promises } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @agentproto/runtime v0.1.0-alpha
|
|
7
|
+
* Long-running gateway: MCP server + HTTP transport + HEARTBEAT autonomy + conversation persistence over a workspace dir.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
var CONFIG_VERSION = 1;
|
|
11
|
+
var CONFIG_FILE_PATH = () => join(homedir(), ".agentproto", "config.json");
|
|
12
|
+
async function loadConfig(path) {
|
|
13
|
+
const target = path ?? CONFIG_FILE_PATH();
|
|
14
|
+
try {
|
|
15
|
+
const raw = await promises.readFile(target, "utf8");
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
console.warn(
|
|
21
|
+
`[runtime/config] ${target}: top-level value is not an object \u2014 ignoring`
|
|
22
|
+
);
|
|
23
|
+
return {};
|
|
24
|
+
} catch (err) {
|
|
25
|
+
const code = err.code;
|
|
26
|
+
if (code && code !== "ENOENT") {
|
|
27
|
+
console.warn(
|
|
28
|
+
`[runtime/config] failed to read ${target}: ${err instanceof Error ? err.message : String(err)}`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function saveConfig(next, path) {
|
|
35
|
+
const target = path ?? CONFIG_FILE_PATH();
|
|
36
|
+
const payload = { ...next, version: CONFIG_VERSION };
|
|
37
|
+
const dir = dirname(target);
|
|
38
|
+
await promises.mkdir(dir, { recursive: true });
|
|
39
|
+
const tmp = `${target}.tmp`;
|
|
40
|
+
await promises.writeFile(tmp, JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
41
|
+
await promises.rename(tmp, target);
|
|
42
|
+
}
|
|
43
|
+
function getConfigKey(cfg, dotted) {
|
|
44
|
+
let cur = cfg;
|
|
45
|
+
for (const part of dotted.split(".")) {
|
|
46
|
+
if (cur == null || typeof cur !== "object") return void 0;
|
|
47
|
+
cur = cur[part];
|
|
48
|
+
}
|
|
49
|
+
return cur;
|
|
50
|
+
}
|
|
51
|
+
function setConfigKey(cfg, dotted, value) {
|
|
52
|
+
const parts = dotted.split(".");
|
|
53
|
+
const out = { ...cfg };
|
|
54
|
+
let cur = out;
|
|
55
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
56
|
+
const k = parts[i];
|
|
57
|
+
const next = cur[k];
|
|
58
|
+
if (next && typeof next === "object" && !Array.isArray(next)) {
|
|
59
|
+
cur[k] = { ...next };
|
|
60
|
+
} else {
|
|
61
|
+
cur[k] = {};
|
|
62
|
+
}
|
|
63
|
+
cur = cur[k];
|
|
64
|
+
}
|
|
65
|
+
const leaf = parts[parts.length - 1];
|
|
66
|
+
if (value === void 0) {
|
|
67
|
+
delete cur[leaf];
|
|
68
|
+
} else {
|
|
69
|
+
cur[leaf] = value;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { CONFIG_FILE_PATH, CONFIG_VERSION, getConfigKey, loadConfig, saveConfig, setConfigKey };
|
|
75
|
+
//# sourceMappingURL=config.mjs.map
|
|
76
|
+
//# sourceMappingURL=config.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/config.ts"],"names":["fs"],"mappings":";;;;;;;;;AA+BO,IAAM,cAAA,GAAiB;AAyFvB,IAAM,mBAAmB,MAC9B,IAAA,CAAK,OAAA,EAAQ,EAAG,eAAe,aAAa;AAS9C,eAAsB,WAAW,IAAA,EAA0C;AACzE,EAAA,MAAM,MAAA,GAAS,QAAQ,gBAAA,EAAiB;AACxC,EAAA,IAAI;AACF,IAAA,MAAM,GAAA,GAAM,MAAMA,QAAA,CAAG,QAAA,CAAS,QAAQ,MAAM,CAAA;AAC5C,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,KAAA,CAAM,GAAG,CAAA;AAC7B,IAAA,IAAI,MAAA,IAAU,OAAO,MAAA,KAAW,QAAA,IAAY,CAAC,KAAA,CAAM,OAAA,CAAQ,MAAM,CAAA,EAAG;AAClE,MAAA,OAAO,MAAA;AAAA,IACT;AACA,IAAA,OAAA,CAAQ,IAAA;AAAA,MACN,oBAAoB,MAAM,CAAA,kDAAA;AAAA,KAC5B;AACA,IAAA,OAAO,EAAC;AAAA,EACV,SAAS,GAAA,EAAK;AAEZ,IAAA,MAAM,OAAQ,GAAA,CAA8B,IAAA;AAC5C,IAAA,IAAI,IAAA,IAAQ,SAAS,QAAA,EAAU;AAC7B,MAAA,OAAA,CAAQ,IAAA;AAAA,QACN,CAAA,gCAAA,EAAmC,MAAM,CAAA,EAAA,EACvC,GAAA,YAAe,QAAQ,GAAA,CAAI,OAAA,GAAU,MAAA,CAAO,GAAG,CACjD,CAAA;AAAA,OACF;AAAA,IACF;AACA,IAAA,OAAO,EAAC;AAAA,EACV;AACF;AAcA,eAAsB,UAAA,CACpB,MACA,IAAA,EACe;AACf,EAAA,MAAM,MAAA,GAAS,QAAQ,gBAAA,EAAiB;AACxC,EAAA,MAAM,OAAA,GAAU,EAAE,GAAG,IAAA,EAAM,SAAS,cAAA,EAAe;AACnD,EAAA,MAAM,GAAA,GAAM,QAAQ,MAAM,CAAA;AAC1B,EAAA,MAAMA,SAAG,KAAA,CAAM,GAAA,EAAK,EAAE,SAAA,EAAW,MAAM,CAAA;AACvC,EAAA,MAAM,GAAA,GAAM,GAAG,MAAM,CAAA,IAAA,CAAA;AACrB,EAAA,MAAMA,QAAA,CAAG,SAAA,CAAU,GAAA,EAAK,IAAA,CAAK,SAAA,CAAU,SAAS,IAAA,EAAM,CAAC,CAAA,GAAI,IAAA,EAAM,MAAM,CAAA;AACvE,EAAA,MAAMA,QAAA,CAAG,MAAA,CAAO,GAAA,EAAK,MAAM,CAAA;AAC7B;AAMO,SAAS,YAAA,CACd,KACA,MAAA,EACS;AACT,EAAA,IAAI,GAAA,GAAe,GAAA;AACnB,EAAA,KAAA,MAAW,IAAA,IAAQ,MAAA,CAAO,KAAA,CAAM,GAAG,CAAA,EAAG;AACpC,IAAA,IAAI,GAAA,IAAO,IAAA,IAAQ,OAAO,GAAA,KAAQ,UAAU,OAAO,MAAA;AACnD,IAAA,GAAA,GAAO,IAAgC,IAAI,CAAA;AAAA,EAC7C;AACA,EAAA,OAAO,GAAA;AACT;AAOO,SAAS,YAAA,CACd,GAAA,EACA,MAAA,EACA,KAAA,EACkB;AAClB,EAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,KAAA,CAAM,GAAG,CAAA;AAC9B,EAAA,MAAM,GAAA,GAAwB,EAAE,GAAG,GAAA,EAAI;AACvC,EAAA,IAAI,GAAA,GAA+B,GAAA;AACnC,EAAA,KAAA,IAAS,IAAI,CAAA,EAAG,CAAA,GAAI,KAAA,CAAM,MAAA,GAAS,GAAG,CAAA,EAAA,EAAK;AACzC,IAAA,MAAM,CAAA,GAAI,MAAM,CAAC,CAAA;AACjB,IAAA,MAAM,IAAA,GAAO,IAAI,CAAC,CAAA;AAClB,IAAA,IAAI,IAAA,IAAQ,OAAO,IAAA,KAAS,QAAA,IAAY,CAAC,KAAA,CAAM,OAAA,CAAQ,IAAI,CAAA,EAAG;AAC5D,MAAA,GAAA,CAAI,CAAC,CAAA,GAAI,EAAE,GAAI,IAAA,EAAiC;AAAA,IAClD,CAAA,MAAO;AACL,MAAA,GAAA,CAAI,CAAC,IAAI,EAAC;AAAA,IACZ;AACA,IAAA,GAAA,GAAM,IAAI,CAAC,CAAA;AAAA,EACb;AACA,EAAA,MAAM,IAAA,GAAO,KAAA,CAAM,KAAA,CAAM,MAAA,GAAS,CAAC,CAAA;AACnC,EAAA,IAAI,UAAU,MAAA,EAAW;AACvB,IAAA,OAAO,IAAI,IAAI,CAAA;AAAA,EACjB,CAAA,MAAO;AACL,IAAA,GAAA,CAAI,IAAI,CAAA,GAAI,KAAA;AAAA,EACd;AACA,EAAA,OAAO,GAAA;AACT","file":"config.mjs","sourcesContent":["/**\n * `~/.agentproto/config.json` — single hand-editable JSON for the\n * agentproto control plane's defaults. Sits alongside the existing\n * surface files (workspaces.json, credentials.json, sessions.json):\n *\n * workspaces.json which directories are workspaces + which is active\n * credentials.json tunnel host bearer tokens (mode 0600)\n * sessions.json last-known snapshot of the registry (informational)\n * config.json daemon defaults: port, bind, allowed origins,\n * tunnel host, feature toggles\n *\n * Resolution order for every daemon knob is:\n * 1. CLI flag (e.g. --port)\n * 2. Env var (where one exists, e.g. AGENTPROTO_TOKEN)\n * 3. config.json\n * 4. Hardcoded default\n *\n * This means a user can call `agentproto config set daemon.port 18791`\n * once and never re-pass `--port 18791` to `serve install` etc. CLI\n * flags still win for one-off overrides.\n *\n * Schema is intentionally narrow + extensible — unknown keys are\n * preserved on save (deep-merge), so a newer CLI writing a new\n * field won't drop one an older CLI doesn't know about. No secrets\n * here; credentials stay in credentials.json (mode 0600).\n */\n\nimport { promises as fs } from \"node:fs\"\nimport { homedir } from \"node:os\"\nimport { dirname, join } from \"node:path\"\n\nexport const CONFIG_VERSION = 1 as const\n\nexport interface DaemonConfig {\n /** Absolute path to the workspace the daemon binds to at boot. */\n workspace?: string\n /** HTTP port. Default 18790. */\n port?: number\n /** Bind addr. Default 127.0.0.1. */\n bind?: string\n /** Trusted browser origins for mutating /sessions/* routes (in\n * addition to the hardcoded localhost defaults). */\n allowedOrigins?: string[]\n /** When true, the daemon does NOT auto-trust localhost-on-any-port.\n * Only origins explicitly listed in `allowedOrigins` are allowed.\n * Pair with a curated list (e.g. `[\"http://localhost:3000\"]`) for\n * hardened setups. Default false. */\n strictOrigins?: boolean\n /** Server label sent in tunnel hello frames. */\n label?: string\n}\n\nexport interface TunnelConfig {\n /** Cloud WS URL. When set + autoconnect=true, `agentproto serve`\n * bootstraps with `--connect <host>`. */\n host?: string\n /** apt_ daemon token to present at the tunnel upgrade. When set,\n * `agentproto serve` uses this BEFORE falling back to\n * credentials.json — handy in profiles where the token-per-host\n * mapping in credentials.json doesn't fit (e.g. host = tunnel URL\n * but credentials were minted against the api URL). */\n token?: string\n /** Whether `agentproto daemon start` connects the tunnel by\n * default. v0 only — implementer can ignore until daemon needs it. */\n autoconnect?: boolean\n}\n\nexport interface FeaturesConfig {\n /** Hint that PTY is desired — informational; the daemon still\n * detects node-pty's presence at runtime. */\n pty?: boolean\n}\n\n/**\n * Per-environment connection bundle. A profile overrides specific\n * fields of the top-level `daemon` / `tunnel` / `features` blocks\n * when selected via `--profile <name>` (or the top-level\n * `activeProfile` setting). Missing fields fall through to the\n * top-level config, so a profile only needs to declare what's\n * different — typically just `tunnel.host` + `tunnel.token`.\n *\n * Example:\n * {\n * \"daemon\": { \"workspace\": \"/code\", \"port\": 18790 },\n * \"activeProfile\": \"local\",\n * \"profiles\": {\n * \"local\": { \"tunnel\": { \"host\": \"ws://localhost:3200/connect\",\n * \"token\": \"apt_local\", \"autoconnect\": true } },\n * \"prod\": { \"tunnel\": { \"host\": \"wss://tunnel.guilde.work/connect\",\n * \"token\": \"apt_prod\", \"autoconnect\": true },\n * \"daemon\": { \"port\": 18791 } }\n * }\n * }\n *\n * Sandbox daemons generate per-sandbox profile entries at provision\n * time so the daemon inside the sandbox boots with\n * `agentproto serve --profile sandbox-<id>` and no extra plumbing.\n */\nexport interface ProfileConfig {\n daemon?: DaemonConfig\n tunnel?: TunnelConfig\n features?: FeaturesConfig\n}\n\nexport interface AgentprotoConfig {\n version?: number\n daemon?: DaemonConfig\n tunnel?: TunnelConfig\n features?: FeaturesConfig\n /** Named connection profiles. See `ProfileConfig` for the merge\n * semantics — a profile's fields shallow-override the top-level\n * defaults for the selected run. */\n profiles?: Record<string, ProfileConfig>\n /** Profile name to use when `--profile` isn't passed. When unset,\n * the top-level `daemon` / `tunnel` blocks are used directly. */\n activeProfile?: string\n /** Unknown keys preserved across save round-trips. */\n [unknown: string]: unknown\n}\n\nexport const CONFIG_FILE_PATH = (): string =>\n join(homedir(), \".agentproto\", \"config.json\")\n\n/**\n * Load config.json. Returns an empty object (NOT null) when the file\n * is missing, malformed, or unreadable — callers can `cfg.daemon?.port`\n * safely without null-guards. Errors during a malformed-read are\n * logged once so the user notices the file is broken without the\n * daemon refusing to boot.\n */\nexport async function loadConfig(path?: string): Promise<AgentprotoConfig> {\n const target = path ?? CONFIG_FILE_PATH()\n try {\n const raw = await fs.readFile(target, \"utf8\")\n const parsed = JSON.parse(raw) as unknown\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n return parsed as AgentprotoConfig\n }\n console.warn(\n `[runtime/config] ${target}: top-level value is not an object — ignoring`,\n )\n return {}\n } catch (err) {\n // ENOENT is the common case; only warn on other shapes.\n const code = (err as NodeJS.ErrnoException).code\n if (code && code !== \"ENOENT\") {\n console.warn(\n `[runtime/config] failed to read ${target}: ${\n err instanceof Error ? err.message : String(err)\n }`,\n )\n }\n return {}\n }\n}\n\n/**\n * Write config.json atomically (tmp + rename) so a concurrent\n * `agentproto config edit` can't half-truncate the file. Writes\n * `next` AS-IS — callers are expected to pass the full desired\n * state (loaded the existing config, mutated, passed it back).\n *\n * Earlier versions deep-merged with the on-disk file, but that made\n * deletions impossible: `setConfigKey(cfg, \"x\", undefined)` would\n * remove the key from memory, then the deep-merge would silently\n * re-add it from disk. The current design trusts the caller's\n * snapshot and uses atomic rename for crash safety.\n */\nexport async function saveConfig(\n next: AgentprotoConfig,\n path?: string,\n): Promise<void> {\n const target = path ?? CONFIG_FILE_PATH()\n const payload = { ...next, version: CONFIG_VERSION }\n const dir = dirname(target)\n await fs.mkdir(dir, { recursive: true })\n const tmp = `${target}.tmp`\n await fs.writeFile(tmp, JSON.stringify(payload, null, 2) + \"\\n\", \"utf8\")\n await fs.rename(tmp, target)\n}\n\n/**\n * Read a dot-notation key (`daemon.port`) out of a config. Returns\n * `undefined` when any segment is missing.\n */\nexport function getConfigKey(\n cfg: AgentprotoConfig,\n dotted: string,\n): unknown {\n let cur: unknown = cfg\n for (const part of dotted.split(\".\")) {\n if (cur == null || typeof cur !== \"object\") return undefined\n cur = (cur as Record<string, unknown>)[part]\n }\n return cur\n}\n\n/**\n * Set a dot-notation key in a config. Returns a new object — does\n * NOT mutate. Creates intermediate objects as needed. Setting\n * `value: undefined` is treated as a delete.\n */\nexport function setConfigKey(\n cfg: AgentprotoConfig,\n dotted: string,\n value: unknown,\n): AgentprotoConfig {\n const parts = dotted.split(\".\")\n const out: AgentprotoConfig = { ...cfg }\n let cur: Record<string, unknown> = out as Record<string, unknown>\n for (let i = 0; i < parts.length - 1; i++) {\n const k = parts[i]!\n const next = cur[k]\n if (next && typeof next === \"object\" && !Array.isArray(next)) {\n cur[k] = { ...(next as Record<string, unknown>) }\n } else {\n cur[k] = {}\n }\n cur = cur[k] as Record<string, unknown>\n }\n const leaf = parts[parts.length - 1]!\n if (value === undefined) {\n delete cur[leaf]\n } else {\n cur[leaf] = value\n }\n return out\n}\n\n/**\n * Deep merge — objects are recursively combined, everything else\n * (arrays, primitives) is replaced wholesale by `b`. Mirrors what\n * `Object.assign({}, a, b)` does for shallow keys.\n */\nfunction deepMerge<A extends Record<string, unknown>, B extends Record<string, unknown>>(\n a: A,\n b: B,\n): A & B {\n const out: Record<string, unknown> = { ...a }\n for (const [k, v] of Object.entries(b)) {\n const cur = out[k]\n if (\n v &&\n typeof v === \"object\" &&\n !Array.isArray(v) &&\n cur &&\n typeof cur === \"object\" &&\n !Array.isArray(cur)\n ) {\n out[k] = deepMerge(\n cur as Record<string, unknown>,\n v as Record<string, unknown>,\n )\n } else {\n out[k] = v\n }\n }\n return out as A & B\n}\n"]}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only conversation persistence as plain markdown.
|
|
3
|
+
*
|
|
4
|
+
* Each conversation is a file at `<workspace>/conversations/<id>.md`
|
|
5
|
+
* with a frontmatter header (`schema: conversation/v1`, `id`, `agent`,
|
|
6
|
+
* `started`, `status`) and a body composed of `## <role> — <iso>`
|
|
7
|
+
* blocks. Append-only: turns are concatenated to the body, never
|
|
8
|
+
* rewritten in place. Readers tolerant to clock skew use the timestamp
|
|
9
|
+
* in each block, not file mtime.
|
|
10
|
+
*
|
|
11
|
+
* The format is intentionally trivial — git-diffable, human-editable,
|
|
12
|
+
* survives `cat`. Designed to converge with the AIP runtime/v1 spec
|
|
13
|
+
* (TBD) once it lands.
|
|
14
|
+
*/
|
|
15
|
+
type ConvRole = "user" | "assistant" | "system";
|
|
16
|
+
interface ConversationMeta {
|
|
17
|
+
id: string;
|
|
18
|
+
agent: string;
|
|
19
|
+
started: string;
|
|
20
|
+
status: "open" | "closed";
|
|
21
|
+
}
|
|
22
|
+
interface ConversationTurn {
|
|
23
|
+
role: ConvRole;
|
|
24
|
+
at: string;
|
|
25
|
+
/** Optional sub-attribution after the role (e.g. agent id). */
|
|
26
|
+
attribution?: string;
|
|
27
|
+
content: string;
|
|
28
|
+
}
|
|
29
|
+
interface ConversationStore {
|
|
30
|
+
/**
|
|
31
|
+
* Open a conversation. If the file doesn't exist, it's created with
|
|
32
|
+
* a frontmatter header. If it exists, no-op (idempotent).
|
|
33
|
+
*/
|
|
34
|
+
open(id: string, meta: {
|
|
35
|
+
agent: string;
|
|
36
|
+
}): Promise<void>;
|
|
37
|
+
appendTurn(id: string, role: ConvRole, content: string, opts?: {
|
|
38
|
+
attribution?: string;
|
|
39
|
+
at?: string;
|
|
40
|
+
}): Promise<void>;
|
|
41
|
+
read(id: string): Promise<{
|
|
42
|
+
meta: ConversationMeta;
|
|
43
|
+
turns: ConversationTurn[];
|
|
44
|
+
}>;
|
|
45
|
+
list(): Promise<Array<{
|
|
46
|
+
id: string;
|
|
47
|
+
meta: ConversationMeta;
|
|
48
|
+
}>>;
|
|
49
|
+
/** Workspace-relative path to the conv file, for tooling. */
|
|
50
|
+
pathFor(id: string): string;
|
|
51
|
+
}
|
|
52
|
+
interface FileConversationStoreOptions {
|
|
53
|
+
/** Absolute workspace root. */
|
|
54
|
+
workspace: string;
|
|
55
|
+
/** Override `<workspace>/conversations`. */
|
|
56
|
+
dir?: string;
|
|
57
|
+
}
|
|
58
|
+
declare function fileConversationStore(opts: FileConversationStoreOptions): ConversationStore;
|
|
59
|
+
|
|
60
|
+
export { type ConvRole, type ConversationMeta, type ConversationStore, type ConversationTurn, type FileConversationStoreOptions, fileConversationStore };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { readdir, readFile, writeFile, mkdir } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @agentproto/runtime v0.1.0-alpha
|
|
7
|
+
* Long-running gateway: MCP server + HTTP transport + HEARTBEAT autonomy + conversation persistence over a workspace dir.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
var FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n/;
|
|
11
|
+
function fileConversationStore(opts) {
|
|
12
|
+
const dir = opts.dir ?? join(opts.workspace, "conversations");
|
|
13
|
+
const filePath = (id) => join(dir, `${id}.md`);
|
|
14
|
+
return {
|
|
15
|
+
pathFor: filePath,
|
|
16
|
+
async open(id, meta) {
|
|
17
|
+
await mkdir(dir, { recursive: true });
|
|
18
|
+
const path = filePath(id);
|
|
19
|
+
if (existsSync(path)) return;
|
|
20
|
+
const header = renderHeader({
|
|
21
|
+
id,
|
|
22
|
+
agent: meta.agent,
|
|
23
|
+
started: (/* @__PURE__ */ new Date()).toISOString(),
|
|
24
|
+
status: "open"
|
|
25
|
+
});
|
|
26
|
+
await writeFile(path, header, "utf8");
|
|
27
|
+
},
|
|
28
|
+
async appendTurn(id, role, content, options) {
|
|
29
|
+
const path = filePath(id);
|
|
30
|
+
if (!existsSync(path)) {
|
|
31
|
+
await this.open(id, { agent: options?.attribution ?? "unknown" });
|
|
32
|
+
}
|
|
33
|
+
const block = renderTurn({
|
|
34
|
+
role,
|
|
35
|
+
at: options?.at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
36
|
+
attribution: options?.attribution,
|
|
37
|
+
content
|
|
38
|
+
});
|
|
39
|
+
await writeFile(path, block, { encoding: "utf8", flag: "a" });
|
|
40
|
+
},
|
|
41
|
+
async read(id) {
|
|
42
|
+
const path = filePath(id);
|
|
43
|
+
const source = await readFile(path, "utf8");
|
|
44
|
+
return parseConversation(source, id);
|
|
45
|
+
},
|
|
46
|
+
async list() {
|
|
47
|
+
if (!existsSync(dir)) return [];
|
|
48
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
52
|
+
const id = entry.name.slice(0, -3);
|
|
53
|
+
try {
|
|
54
|
+
const source = await readFile(join(dir, entry.name), "utf8");
|
|
55
|
+
const { meta } = parseConversation(source, id);
|
|
56
|
+
out.push({ id, meta });
|
|
57
|
+
} catch {
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function renderHeader(meta) {
|
|
65
|
+
return [
|
|
66
|
+
"---",
|
|
67
|
+
`schema: conversation/v1`,
|
|
68
|
+
`id: ${meta.id}`,
|
|
69
|
+
`agent: ${meta.agent}`,
|
|
70
|
+
`started: ${meta.started}`,
|
|
71
|
+
`status: ${meta.status}`,
|
|
72
|
+
"---",
|
|
73
|
+
"",
|
|
74
|
+
""
|
|
75
|
+
].join("\n");
|
|
76
|
+
}
|
|
77
|
+
function renderTurn(turn) {
|
|
78
|
+
const heading = turn.attribution ? `## ${turn.role} \u2014 ${turn.at} (${turn.attribution})` : `## ${turn.role} \u2014 ${turn.at}`;
|
|
79
|
+
return `${heading}
|
|
80
|
+
|
|
81
|
+
${turn.content.trimEnd()}
|
|
82
|
+
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
85
|
+
function parseConversation(source, fallbackId) {
|
|
86
|
+
const match = source.match(FRONTMATTER_RE);
|
|
87
|
+
let meta = {
|
|
88
|
+
id: fallbackId,
|
|
89
|
+
agent: "unknown",
|
|
90
|
+
started: "",
|
|
91
|
+
status: "open"
|
|
92
|
+
};
|
|
93
|
+
let body = source;
|
|
94
|
+
if (match) {
|
|
95
|
+
meta = parseFrontmatter(match[1] ?? "", fallbackId);
|
|
96
|
+
body = source.slice(match[0].length);
|
|
97
|
+
}
|
|
98
|
+
const turns = [];
|
|
99
|
+
const lines = body.split("\n");
|
|
100
|
+
let current = null;
|
|
101
|
+
let buffer = [];
|
|
102
|
+
const flush = () => {
|
|
103
|
+
if (!current) return;
|
|
104
|
+
current.content = buffer.join("\n").trim();
|
|
105
|
+
turns.push(current);
|
|
106
|
+
current = null;
|
|
107
|
+
buffer = [];
|
|
108
|
+
};
|
|
109
|
+
const headingRe = /^##\s+(user|assistant|system)\s+—\s+(\S+)(?:\s+\(([^)]+)\))?\s*$/;
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
const m = line.match(headingRe);
|
|
112
|
+
if (m) {
|
|
113
|
+
flush();
|
|
114
|
+
current = {
|
|
115
|
+
role: m[1],
|
|
116
|
+
at: m[2] ?? "",
|
|
117
|
+
attribution: m[3],
|
|
118
|
+
content: ""
|
|
119
|
+
};
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (current) buffer.push(line);
|
|
123
|
+
}
|
|
124
|
+
flush();
|
|
125
|
+
return { meta, turns };
|
|
126
|
+
}
|
|
127
|
+
function parseFrontmatter(raw, fallbackId) {
|
|
128
|
+
const out = {};
|
|
129
|
+
for (const line of raw.split("\n")) {
|
|
130
|
+
const colon = line.indexOf(":");
|
|
131
|
+
if (colon < 1) continue;
|
|
132
|
+
const key = line.slice(0, colon).trim();
|
|
133
|
+
const value = line.slice(colon + 1).trim();
|
|
134
|
+
out[key] = value;
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
id: out.id ?? fallbackId,
|
|
138
|
+
agent: out.agent ?? "unknown",
|
|
139
|
+
started: out.started ?? "",
|
|
140
|
+
status: out.status === "closed" ? "closed" : "open"
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export { fileConversationStore };
|
|
145
|
+
//# sourceMappingURL=conversations.mjs.map
|
|
146
|
+
//# sourceMappingURL=conversations.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/conversations.ts"],"names":[],"mappings":"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","file":"conversations.mjs","sourcesContent":["/**\n * Append-only conversation persistence as plain markdown.\n *\n * Each conversation is a file at `<workspace>/conversations/<id>.md`\n * with a frontmatter header (`schema: conversation/v1`, `id`, `agent`,\n * `started`, `status`) and a body composed of `## <role> — <iso>`\n * blocks. Append-only: turns are concatenated to the body, never\n * rewritten in place. Readers tolerant to clock skew use the timestamp\n * in each block, not file mtime.\n *\n * The format is intentionally trivial — git-diffable, human-editable,\n * survives `cat`. Designed to converge with the AIP runtime/v1 spec\n * (TBD) once it lands.\n */\n\nimport { existsSync } from \"node:fs\"\nimport { mkdir, readFile, readdir, writeFile } from \"node:fs/promises\"\nimport { join } from \"node:path\"\n\nconst FRONTMATTER_RE = /^---\\n([\\s\\S]*?)\\n---\\n/\n\nexport type ConvRole = \"user\" | \"assistant\" | \"system\"\n\nexport interface ConversationMeta {\n id: string\n agent: string\n started: string\n status: \"open\" | \"closed\"\n}\n\nexport interface ConversationTurn {\n role: ConvRole\n at: string\n /** Optional sub-attribution after the role (e.g. agent id). */\n attribution?: string\n content: string\n}\n\nexport interface ConversationStore {\n /**\n * Open a conversation. If the file doesn't exist, it's created with\n * a frontmatter header. If it exists, no-op (idempotent).\n */\n open(id: string, meta: { agent: string }): Promise<void>\n appendTurn(\n id: string,\n role: ConvRole,\n content: string,\n opts?: { attribution?: string; at?: string },\n ): Promise<void>\n read(\n id: string,\n ): Promise<{ meta: ConversationMeta; turns: ConversationTurn[] }>\n list(): Promise<Array<{ id: string; meta: ConversationMeta }>>\n /** Workspace-relative path to the conv file, for tooling. */\n pathFor(id: string): string\n}\n\nexport interface FileConversationStoreOptions {\n /** Absolute workspace root. */\n workspace: string\n /** Override `<workspace>/conversations`. */\n dir?: string\n}\n\nexport function fileConversationStore(\n opts: FileConversationStoreOptions,\n): ConversationStore {\n const dir = opts.dir ?? join(opts.workspace, \"conversations\")\n\n const filePath = (id: string) => join(dir, `${id}.md`)\n\n return {\n pathFor: filePath,\n\n async open(id, meta) {\n await mkdir(dir, { recursive: true })\n const path = filePath(id)\n if (existsSync(path)) return\n const header = renderHeader({\n id,\n agent: meta.agent,\n started: new Date().toISOString(),\n status: \"open\",\n })\n await writeFile(path, header, \"utf8\")\n },\n\n async appendTurn(id, role, content, options) {\n const path = filePath(id)\n // Lazy-open: if a caller appends without opening, create the\n // file so the operation never silently drops a turn.\n if (!existsSync(path)) {\n await this.open(id, { agent: options?.attribution ?? \"unknown\" })\n }\n const block = renderTurn({\n role,\n at: options?.at ?? new Date().toISOString(),\n attribution: options?.attribution,\n content,\n })\n // Append a leading blank line so the new block is always\n // separated from whatever's above (header or prev turn).\n await writeFile(path, block, { encoding: \"utf8\", flag: \"a\" })\n },\n\n async read(id) {\n const path = filePath(id)\n const source = await readFile(path, \"utf8\")\n return parseConversation(source, id)\n },\n\n async list() {\n if (!existsSync(dir)) return []\n const entries = await readdir(dir, { withFileTypes: true })\n const out: Array<{ id: string; meta: ConversationMeta }> = []\n for (const entry of entries) {\n if (!entry.isFile() || !entry.name.endsWith(\".md\")) continue\n const id = entry.name.slice(0, -3)\n try {\n const source = await readFile(join(dir, entry.name), \"utf8\")\n const { meta } = parseConversation(source, id)\n out.push({ id, meta })\n } catch {\n // Skip malformed conv files rather than crash list().\n }\n }\n return out\n },\n }\n}\n\n// ── render ───────────────────────────────────────────────────────────\n\nfunction renderHeader(meta: ConversationMeta): string {\n return [\n \"---\",\n `schema: conversation/v1`,\n `id: ${meta.id}`,\n `agent: ${meta.agent}`,\n `started: ${meta.started}`,\n `status: ${meta.status}`,\n \"---\",\n \"\",\n \"\",\n ].join(\"\\n\")\n}\n\nfunction renderTurn(turn: ConversationTurn): string {\n const heading = turn.attribution\n ? `## ${turn.role} — ${turn.at} (${turn.attribution})`\n : `## ${turn.role} — ${turn.at}`\n return `${heading}\\n\\n${turn.content.trimEnd()}\\n\\n`\n}\n\n// ── parse ────────────────────────────────────────────────────────────\n\ninterface ParsedConversation {\n meta: ConversationMeta\n turns: ConversationTurn[]\n}\n\nfunction parseConversation(source: string, fallbackId: string): ParsedConversation {\n const match = source.match(FRONTMATTER_RE)\n let meta: ConversationMeta = {\n id: fallbackId,\n agent: \"unknown\",\n started: \"\",\n status: \"open\",\n }\n let body = source\n if (match) {\n meta = parseFrontmatter(match[1] ?? \"\", fallbackId)\n body = source.slice(match[0].length)\n }\n\n const turns: ConversationTurn[] = []\n // Split on `## <role> — <iso>` headings. Conservative — anything\n // that doesn't match is treated as continuation of the prior turn.\n const lines = body.split(\"\\n\")\n let current: ConversationTurn | null = null\n let buffer: string[] = []\n\n const flush = () => {\n if (!current) return\n current.content = buffer.join(\"\\n\").trim()\n turns.push(current)\n current = null\n buffer = []\n }\n\n const headingRe =\n /^##\\s+(user|assistant|system)\\s+—\\s+(\\S+)(?:\\s+\\(([^)]+)\\))?\\s*$/\n\n for (const line of lines) {\n const m = line.match(headingRe)\n if (m) {\n flush()\n current = {\n role: m[1] as ConvRole,\n at: m[2] ?? \"\",\n attribution: m[3],\n content: \"\",\n }\n continue\n }\n if (current) buffer.push(line)\n }\n flush()\n\n return { meta, turns }\n}\n\nfunction parseFrontmatter(\n raw: string,\n fallbackId: string,\n): ConversationMeta {\n const out: Record<string, string> = {}\n for (const line of raw.split(\"\\n\")) {\n const colon = line.indexOf(\":\")\n if (colon < 1) continue\n const key = line.slice(0, colon).trim()\n const value = line.slice(colon + 1).trim()\n out[key] = value\n }\n return {\n id: out.id ?? fallbackId,\n agent: out.agent ?? \"unknown\",\n started: out.started ?? \"\",\n status: (out.status === \"closed\" ? \"closed\" : \"open\"),\n }\n}\n"]}
|