@dcasegr/skein 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +525 -2
  2. package/bin/skein-host.js +22 -0
  3. package/bin/skein.js +8 -0
  4. package/dist/client/admin-cli.d.ts +10 -0
  5. package/dist/client/admin-cli.js +370 -0
  6. package/dist/client/admin.d.ts +139 -0
  7. package/dist/client/admin.js +256 -0
  8. package/dist/client/bundle.d.ts +9 -0
  9. package/dist/client/bundle.js +34 -0
  10. package/dist/client/cli.d.ts +44 -0
  11. package/dist/client/cli.js +306 -0
  12. package/dist/client/client.d.ts +161 -0
  13. package/dist/client/client.js +237 -0
  14. package/dist/client/config.d.ts +18 -0
  15. package/dist/client/config.js +31 -0
  16. package/dist/client/conversation.d.ts +62 -0
  17. package/dist/client/conversation.js +113 -0
  18. package/dist/client/raw.d.ts +94 -0
  19. package/dist/client/raw.js +154 -0
  20. package/dist/client/target.d.ts +34 -0
  21. package/dist/client/target.js +158 -0
  22. package/dist/dev/cli.d.ts +8 -0
  23. package/dist/dev/cli.js +248 -0
  24. package/dist/dev/scan.d.ts +15 -0
  25. package/dist/dev/scan.js +173 -0
  26. package/dist/envelope-cbor.d.ts +110 -0
  27. package/dist/envelope-cbor.js +160 -0
  28. package/dist/envelope.d.ts +38 -0
  29. package/dist/envelope.js +101 -0
  30. package/dist/host/arc.d.ts +131 -0
  31. package/dist/host/arc.js +425 -0
  32. package/dist/host/billing.d.ts +102 -0
  33. package/dist/host/billing.js +167 -0
  34. package/dist/host/boot.d.ts +246 -0
  35. package/dist/host/boot.js +643 -0
  36. package/dist/host/cli.d.ts +57 -0
  37. package/dist/host/cli.js +1253 -0
  38. package/dist/host/clock.d.ts +3 -0
  39. package/dist/host/clock.js +8 -0
  40. package/dist/host/control.d.ts +45 -0
  41. package/dist/host/control.js +170 -0
  42. package/dist/host/cron.d.ts +99 -0
  43. package/dist/host/cron.js +196 -0
  44. package/dist/host/deliver.d.ts +53 -0
  45. package/dist/host/deliver.js +251 -0
  46. package/dist/host/deploy.d.ts +29 -0
  47. package/dist/host/deploy.js +55 -0
  48. package/dist/host/door-answer.d.ts +24 -0
  49. package/dist/host/door-answer.js +21 -0
  50. package/dist/host/events.d.ts +89 -0
  51. package/dist/host/events.js +222 -0
  52. package/dist/host/fake-arcade.d.ts +40 -0
  53. package/dist/host/fake-arcade.js +149 -0
  54. package/dist/host/fake-discovery.d.ts +4 -0
  55. package/dist/host/fake-discovery.js +58 -0
  56. package/dist/host/feeds.d.ts +127 -0
  57. package/dist/host/feeds.js +422 -0
  58. package/dist/host/frontdoor.d.ts +92 -0
  59. package/dist/host/frontdoor.js +172 -0
  60. package/dist/host/genesis.d.ts +259 -0
  61. package/dist/host/genesis.js +353 -0
  62. package/dist/host/handles.d.ts +40 -0
  63. package/dist/host/handles.js +88 -0
  64. package/dist/host/hostenv.d.ts +30 -0
  65. package/dist/host/hostenv.js +83 -0
  66. package/dist/host/image-chain.d.ts +127 -0
  67. package/dist/host/image-chain.js +440 -0
  68. package/dist/host/install.d.ts +22 -0
  69. package/dist/host/install.js +139 -0
  70. package/dist/host/instances.d.ts +227 -0
  71. package/dist/host/instances.js +430 -0
  72. package/dist/host/kernel.d.ts +246 -0
  73. package/dist/host/kernel.js +603 -0
  74. package/dist/host/liveness.d.ts +65 -0
  75. package/dist/host/liveness.js +155 -0
  76. package/dist/host/manifest.d.ts +160 -0
  77. package/dist/host/manifest.js +597 -0
  78. package/dist/host/p2p.d.ts +337 -0
  79. package/dist/host/p2p.js +1002 -0
  80. package/dist/host/packet.d.ts +121 -0
  81. package/dist/host/packet.js +562 -0
  82. package/dist/host/plan.d.ts +154 -0
  83. package/dist/host/plan.js +418 -0
  84. package/dist/host/providers.d.ts +238 -0
  85. package/dist/host/providers.js +565 -0
  86. package/dist/host/roster.d.ts +72 -0
  87. package/dist/host/roster.js +172 -0
  88. package/dist/host/router.d.ts +782 -0
  89. package/dist/host/router.js +2411 -0
  90. package/dist/host/signer.d.ts +49 -0
  91. package/dist/host/signer.js +167 -0
  92. package/dist/host/store-sqlite.d.ts +39 -0
  93. package/dist/host/store-sqlite.js +170 -0
  94. package/dist/host/stores.d.ts +43 -0
  95. package/dist/host/stores.js +342 -0
  96. package/dist/host/sync.d.ts +121 -0
  97. package/dist/host/sync.js +467 -0
  98. package/dist/host/testbeef.d.ts +21 -0
  99. package/dist/host/testbeef.js +37 -0
  100. package/dist/host/testhost.d.ts +149 -0
  101. package/dist/host/testhost.js +211 -0
  102. package/dist/host/vcdiff.d.ts +9 -0
  103. package/dist/host/vcdiff.js +210 -0
  104. package/dist/host/wasm-kernel.d.ts +87 -0
  105. package/dist/host/wasm-kernel.js +296 -0
  106. package/dist/host/wasm-worker.d.ts +1 -0
  107. package/dist/host/wasm-worker.js +186 -0
  108. package/dist/peers/cron.d.ts +33 -0
  109. package/dist/peers/cron.js +75 -0
  110. package/dist/peers/infer-main.d.ts +2 -0
  111. package/dist/peers/infer-main.js +74 -0
  112. package/dist/peers/infer.d.ts +163 -0
  113. package/dist/peers/infer.js +367 -0
  114. package/dist/runtime/beef.d.ts +28 -0
  115. package/dist/runtime/beef.js +107 -0
  116. package/dist/runtime/bitcoin.d.ts +36 -0
  117. package/dist/runtime/bitcoin.js +141 -0
  118. package/dist/runtime/cid.d.ts +14 -0
  119. package/dist/runtime/cid.js +51 -0
  120. package/dist/runtime/dispatch.d.ts +92 -0
  121. package/dist/runtime/dispatch.js +142 -0
  122. package/dist/runtime/envelope.d.ts +60 -0
  123. package/dist/runtime/envelope.js +101 -0
  124. package/dist/runtime/heads.d.ts +25 -0
  125. package/dist/runtime/heads.js +37 -0
  126. package/dist/runtime/identity.d.ts +28 -0
  127. package/dist/runtime/identity.js +67 -0
  128. package/dist/runtime/index-store.d.ts +152 -0
  129. package/dist/runtime/index-store.js +715 -0
  130. package/dist/runtime/log.d.ts +62 -0
  131. package/dist/runtime/log.js +104 -0
  132. package/dist/runtime/programs.d.ts +30 -0
  133. package/dist/runtime/programs.js +61 -0
  134. package/dist/runtime/records.d.ts +135 -0
  135. package/dist/runtime/records.js +149 -0
  136. package/dist/runtime/sqlite.d.ts +33 -0
  137. package/dist/runtime/sqlite.js +604 -0
  138. package/dist/runtime/store.d.ts +141 -0
  139. package/dist/runtime/store.js +24 -0
  140. package/dist/runtime/syscalls.d.ts +9 -0
  141. package/dist/runtime/syscalls.js +41 -0
  142. package/dist/runtime/tree.d.ts +60 -0
  143. package/dist/runtime/tree.js +196 -0
  144. package/dist/runtime/types.d.ts +100 -0
  145. package/dist/runtime/types.js +7 -0
  146. package/dist/testapps.d.ts +101 -0
  147. package/dist/testapps.js +275 -0
  148. package/dist/testkit.d.ts +25 -0
  149. package/dist/testkit.js +48 -0
  150. package/dist/wallet.d.ts +35 -0
  151. package/dist/wallet.js +87 -0
  152. package/images/README.md +68 -0
  153. package/images/default/README.md +38 -0
  154. package/images/default/bin/frontdoor.cid +1 -0
  155. package/images/default/bin/frontdoor.json +5 -0
  156. package/images/default/bin/messagebox.cid +1 -0
  157. package/images/default/bin/messagebox.json +8 -0
  158. package/images/default/bin/wallet.cid +1 -0
  159. package/images/default/bin/wallet.json +14 -0
  160. package/images/default/etc/config.json +1 -0
  161. package/images/default/etc/dispatch.json +82 -0
  162. package/images/host/etc/apps.json +8 -0
  163. package/kernel-zig/README.md +1247 -0
  164. package/kernel-zig/web/client.js +65 -0
  165. package/kernel-zig/web/kernel.d.ts +59 -0
  166. package/kernel-zig/web/kernel.js +329 -0
  167. package/kernel-zig/web/store.js +67 -0
  168. package/kernel-zig/web/worker.js +79 -0
  169. package/kernel-zig/zig-out/web/skein-kernel.wasm +0 -0
  170. package/package.json +77 -4
  171. package/wasm/README.md +35 -0
  172. package/wasm/frontdoor.wasm +0 -0
  173. package/wasm/messagebox.wasm +0 -0
  174. package/wasm/resolve.wasm +0 -0
  175. package/wasm/wallet.wasm +0 -0
  176. package/wasm/wire-probe.wasm +0 -0
@@ -0,0 +1,161 @@
1
+ import { CID } from "multiformats/cid";
2
+ import { type WalletInterface } from "../wallet.ts";
3
+ import { type ScanOptions } from "../dev/scan.ts";
4
+ import { type Rec } from "./bundle.ts";
5
+ import type { ClientConfig } from "./config.ts";
6
+ import { RawBox } from "./raw.ts";
7
+ import { type Conversation } from "./conversation.ts";
8
+ export declare const BOX: {
9
+ readonly objects: "objects";
10
+ readonly run: "shell/run";
11
+ readonly head: "head";
12
+ readonly dispatch: "dispatch";
13
+ readonly results: "results";
14
+ readonly chat: "chat";
15
+ };
16
+ /** The boxes David reads, in this order: `run` results, then `chat` (the instance's replies, and chats from others). */
17
+ export declare const INBOX: readonly ["results", "chat"];
18
+ /** A dispatch table change for a mailbox row: (sender — anyone when absent, else an identity key in hex —, box) → handler. */
19
+ export interface DispatchArgs {
20
+ op: "add" | "remove"; /** gone (#143): refused */
21
+ sender?: string;
22
+ box: string;
23
+ handler: string;
24
+ fn?: string;
25
+ }
26
+ /**
27
+ * A handler: a program record's CID, or the name of one in `programs` (an
28
+ * instance's genesis `programs`: name → record CID, as skein-host reads it
29
+ * from the store; the client has no store, so it takes CIDs).
30
+ */
31
+ export declare function handlerCid(s: string, programs?: Record<string, CID>): CID;
32
+ /**
33
+ * The kernel's `dispatch` operation's body (box `dispatch`, from root):
34
+ * {op, row} with row a mailbox route {transport, address: box, program, fn?};
35
+ * a handler named from `programs` (handlerCid). No sender (#143: a route has
36
+ * none — roles gate functions, granted by root).
37
+ */
38
+ export declare function dispatchBody(a: DispatchArgs, programs?: Record<string, CID>): {
39
+ op: "add" | "remove";
40
+ row: Record<string, unknown>;
41
+ };
42
+ /** A directory as git objects held in memory: [root, records]. `opts.ignore` as scan's. */
43
+ export declare function hashDir(dir: string, opts?: ScanOptions): Promise<{
44
+ root: CID;
45
+ records: Rec[];
46
+ }>;
47
+ /**
48
+ * A directory as the `objects` box takes it: ≤ 1 MiB bundles, blobs before
49
+ * trees and the root tree last, so a receiver storing in order never holds a
50
+ * tree whose children have not arrived yet; the last bundle names the root.
51
+ * `skip` drops records the receiver already has; if it has the root tree it
52
+ * has everything (trees arrive after their children), and nothing is sent.
53
+ */
54
+ export declare function dirBundles(dir: string, o?: ScanOptions & {
55
+ limit?: number;
56
+ skip?: (cid: CID) => Promise<boolean>;
57
+ }): Promise<{
58
+ root: CID;
59
+ records: number;
60
+ bundles: Uint8Array[];
61
+ }>;
62
+ /** dirBundles for records already in hand (every object of the tree `root`). */
63
+ export declare function recordBundles(root: CID, all: Rec[], o?: {
64
+ limit?: number;
65
+ skip?: (cid: CID) => Promise<boolean>;
66
+ }): Promise<{
67
+ root: CID;
68
+ records: number;
69
+ bundles: Uint8Array[];
70
+ }>;
71
+ export interface Sent {
72
+ cid: string;
73
+ box: string;
74
+ messageId: string;
75
+ at: string;
76
+ tree?: string;
77
+ cmd?: string;
78
+ text?: string;
79
+ replyTo?: string;
80
+ }
81
+ export interface Result {
82
+ box: string;
83
+ messageId: string;
84
+ /** The message's id as a CID (the instance's `chat` reply's is the next chat's replyTo). */
85
+ cid?: string;
86
+ sender: string;
87
+ /** Always true: the sender is the BRC-104 session's identity, which the mailbox checked. */
88
+ verified: boolean;
89
+ created?: string;
90
+ body?: Record<string, unknown>;
91
+ error?: string;
92
+ }
93
+ /** A verified `chat` from the instance that is a reply (`replyTo` set): what the next chat continues from. */
94
+ export declare function isInstanceReply(r: Result, instance: string): boolean;
95
+ export declare class SkeinClient {
96
+ readonly cfg: ClientConfig;
97
+ readonly wallet: WalletInterface;
98
+ /** The instance's front door. */
99
+ readonly toInstance: RawBox;
100
+ /** David's mailbox (instance). */
101
+ readonly mailbox: RawBox;
102
+ constructor(cfg: ClientConfig, wallet?: WalletInterface, o?: {
103
+ fetch?: typeof fetch;
104
+ });
105
+ identityKey(): Promise<string>;
106
+ /** Send `body` (dag-cbor encoded here) to the instance's `box`. */
107
+ send(box: string, body: unknown, note?: Partial<Sent>): Promise<Sent>;
108
+ importDir(dir: string, onBundle?: (i: number, n: number, bytes: number) => void): Promise<{
109
+ root: CID;
110
+ records: number;
111
+ bundles: Sent[];
112
+ }>;
113
+ /** Send already-encoded body bytes (dag-cbor) to the instance's `box`: its front door's sendMessage. */
114
+ sendBytes(box: string, body: Uint8Array, note?: Partial<Sent>): Promise<Sent>;
115
+ /** Run a command (box `run`). No tree: the instance's `main` head, else the empty tree. */
116
+ run(args: {
117
+ tree?: string;
118
+ cmd: string;
119
+ cwd?: string;
120
+ env?: Record<string, string>;
121
+ }): Promise<Sent>;
122
+ /** Move a named head to a tree the instance holds (box `head`): "`name` is now `tree`". */
123
+ head(name: string, tree: string): Promise<Sent>;
124
+ /** Change the instance's dispatch table (box `dispatch`, a kernel operation): add or remove the mailbox row (sender, box) → handler. No reply. */
125
+ dispatch(a: DispatchArgs): Promise<Sent>;
126
+ /**
127
+ * Chat with the instance (box `chat`). Continues the conversation in
128
+ * conversation.json (replyTo = the instance's last reply, tree defaults to its tree)
129
+ * unless `fresh`.
130
+ */
131
+ chat(args: {
132
+ text: string;
133
+ tree?: string;
134
+ model?: string;
135
+ fresh?: boolean;
136
+ }): Promise<Sent>;
137
+ conversation(): Conversation | undefined;
138
+ /**
139
+ * Read (and by default acknowledge) David's boxes, `results` then `chat`.
140
+ * The newest well-formed, verified `chat` reply from the instance becomes
141
+ * the conversation state.
142
+ */
143
+ inbox(opts?: {
144
+ ack?: boolean;
145
+ }): Promise<Result[]>;
146
+ /**
147
+ * Poll the inbox every `intervalMs` until a message in one of `want`'s boxes
148
+ * answers (replyTo) the paired CID; every message read goes to `onResult`.
149
+ * Resolves the matching result, or undefined at the deadline.
150
+ */
151
+ waitFor(want: {
152
+ box: string;
153
+ replyTo: string;
154
+ }[], opts: {
155
+ timeoutMs: number;
156
+ intervalMs?: number;
157
+ ack?: boolean;
158
+ onResult?: (r: Result) => void;
159
+ }): Promise<Result | undefined>;
160
+ lastSent(box?: string): Sent | undefined;
161
+ }
@@ -0,0 +1,237 @@
1
+ // David's client: a peer that signs as David through his wallet-api (docs/ARCH.md
2
+ // "Everything outside is a peer"; docs/MESSAGES.md). It speaks raw BRC-33 on a
3
+ // BRC-104 session (#40, raw.ts): a message is sent to the instance's own front
4
+ // door (its URL: the instance is an HTTP server), and David's boxes are read
5
+ // from his mailbox — the mailbox instance the instance delivers to (the one
6
+ // peer its genesis names, etc/config.json `owner.messagebox`). No envelope: the
7
+ // session proves who sends; a message's id is its record's CID, which a reply's
8
+ // `replyTo` names.
9
+ import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import * as dagCbor from "@ipld/dag-cbor";
12
+ import { CID } from "multiformats/cid";
13
+ import { remoteWallet } from "../wallet.js";
14
+ import { scan } from "../dev/scan.js";
15
+ import { chunk } from "./bundle.js";
16
+ import { RawBox } from "./raw.js";
17
+ import { chatBody, conversationFrom, loadConversation, parseReply, saveConversation } from "./conversation.js";
18
+ export const BOX = { objects: "objects", run: "shell/run", head: "head", dispatch: "dispatch", results: "results", chat: "chat" };
19
+ /** The boxes David reads, in this order: `run` results, then `chat` (the instance's replies, and chats from others). */
20
+ export const INBOX = [BOX.results, BOX.chat];
21
+ /**
22
+ * A handler: a program record's CID, or the name of one in `programs` (an
23
+ * instance's genesis `programs`: name → record CID, as skein-host reads it
24
+ * from the store; the client has no store, so it takes CIDs).
25
+ */
26
+ export function handlerCid(s, programs = {}) {
27
+ const named = programs[s];
28
+ if (named)
29
+ return named;
30
+ try {
31
+ return CID.parse(s);
32
+ }
33
+ catch {
34
+ const names = Object.keys(programs);
35
+ throw new Error(`handler ${JSON.stringify(s)}: not a CID${names.length ? ` or a program name (${names.join(", ")})` : " (a program by name needs the instance's genesis: skein routes --instance or an origin)"}`);
36
+ }
37
+ }
38
+ /**
39
+ * The kernel's `dispatch` operation's body (box `dispatch`, from root):
40
+ * {op, row} with row a mailbox route {transport, address: box, program, fn?};
41
+ * a handler named from `programs` (handlerCid). No sender (#143: a route has
42
+ * none — roles gate functions, granted by root).
43
+ */
44
+ export function dispatchBody(a, programs = {}) {
45
+ if (a.sender !== undefined)
46
+ throw new Error("sender: gone (#143) — a route has no sender; gate the function with a role and grant it (skein grant)");
47
+ return { op: a.op, row: { transport: "mailbox", address: a.box, program: handlerCid(a.handler, programs), ...(a.fn ? { fn: a.fn } : {}) } };
48
+ }
49
+ /** A directory as git objects held in memory: [root, records]. `opts.ignore` as scan's. */
50
+ export async function hashDir(dir, opts = {}) {
51
+ const m = new Map();
52
+ const blocks = {
53
+ has: async (cid) => m.has(cid.toString()),
54
+ bytes: async (cid) => { const r = m.get(cid.toString()); if (!r)
55
+ throw new Error(`missing ${cid}`); return r.bytes; },
56
+ putBlock: async (cid, bytes) => { m.set(cid.toString(), { cid, bytes }); },
57
+ };
58
+ const root = await scan(blocks, dir, opts);
59
+ return { root, records: [...m.values()] };
60
+ }
61
+ /**
62
+ * A directory as the `objects` box takes it: ≤ 1 MiB bundles, blobs before
63
+ * trees and the root tree last, so a receiver storing in order never holds a
64
+ * tree whose children have not arrived yet; the last bundle names the root.
65
+ * `skip` drops records the receiver already has; if it has the root tree it
66
+ * has everything (trees arrive after their children), and nothing is sent.
67
+ */
68
+ export async function dirBundles(dir, o = {}) {
69
+ const { root, records: all } = await hashDir(dir, o);
70
+ return recordBundles(root, all, o);
71
+ }
72
+ /** dirBundles for records already in hand (every object of the tree `root`). */
73
+ export async function recordBundles(root, all, o = {}) {
74
+ const rootKey = root.toString();
75
+ const records = [];
76
+ if (!(await o.skip?.(root)))
77
+ for (const r of all)
78
+ if (r.cid.toString() === rootKey || !(await o.skip?.(r.cid)))
79
+ records.push(r);
80
+ const rank = (r) => (r.cid.toString() === rootKey ? 2 : new TextDecoder().decode(r.bytes.subarray(0, 5)) === "tree " ? 1 : 0);
81
+ records.sort((a, b) => rank(a) - rank(b));
82
+ return { root, records: records.length, bundles: [...chunk(records, o.limit, root)] };
83
+ }
84
+ /** A verified `chat` from the instance that is a reply (`replyTo` set): what the next chat continues from. */
85
+ export function isInstanceReply(r, instance) {
86
+ return r.box === BOX.chat && !r.error && !!r.cid && r.sender === instance && r.body?.replyTo !== undefined;
87
+ }
88
+ export class SkeinClient {
89
+ cfg;
90
+ wallet;
91
+ /** The instance's front door. */
92
+ toInstance;
93
+ /** David's mailbox (instance). */
94
+ mailbox;
95
+ constructor(cfg, wallet, o = {}) {
96
+ this.cfg = cfg;
97
+ this.wallet = wallet ?? remoteWallet(cfg.walletUrl, cfg.originator);
98
+ this.toInstance = new RawBox(this.wallet, cfg.instanceUrl, { originator: cfg.originator, fetch: o.fetch });
99
+ this.mailbox = new RawBox(this.wallet, cfg.mailboxUrl, { originator: cfg.originator, fetch: o.fetch });
100
+ }
101
+ async identityKey() {
102
+ return (await this.wallet.getPublicKey({ identityKey: true })).publicKey;
103
+ }
104
+ /** Send `body` (dag-cbor encoded here) to the instance's `box`. */
105
+ send(box, body, note = {}) {
106
+ return this.sendBytes(box, dagCbor.encode(body), note);
107
+ }
108
+ async importDir(dir, onBundle) {
109
+ const { root, records, bundles } = await dirBundles(dir);
110
+ const rootKey = root.toString();
111
+ const sent = [];
112
+ for (const [i, bytes] of bundles.entries()) {
113
+ onBundle?.(i, bundles.length, bytes.length);
114
+ // The bundle is already dag-cbor; send it as the body bytes, not re-wrapped.
115
+ sent.push(await this.sendBytes(BOX.objects, bytes, { tree: rootKey }));
116
+ }
117
+ return { root, records, bundles: sent };
118
+ }
119
+ /** Send already-encoded body bytes (dag-cbor) to the instance's `box`: its front door's sendMessage. */
120
+ async sendBytes(box, body, note = {}) {
121
+ const { id } = await this.toInstance.send(this.cfg.instance.identityKey, box, body);
122
+ const cid = id.toString();
123
+ const sent = { cid, box, messageId: cid, at: new Date().toISOString(), ...note };
124
+ mkdirSync(this.cfg.stateDir, { recursive: true });
125
+ appendFileSync(join(this.cfg.stateDir, "sent.jsonl"), JSON.stringify(sent) + "\n");
126
+ return sent;
127
+ }
128
+ /** Run a command (box `run`). No tree: the instance's `main` head, else the empty tree. */
129
+ async run(args) {
130
+ const body = { cmd: args.cmd };
131
+ if (args.tree !== undefined)
132
+ body.tree = CID.parse(args.tree);
133
+ if (args.cwd !== undefined)
134
+ body.cwd = args.cwd;
135
+ if (args.env !== undefined)
136
+ body.env = args.env;
137
+ return this.send(BOX.run, body, { tree: args.tree, cmd: args.cmd });
138
+ }
139
+ /** Move a named head to a tree the instance holds (box `head`): "`name` is now `tree`". */
140
+ head(name, tree) {
141
+ return this.send(BOX.head, { name, tree: CID.parse(tree) }, { tree });
142
+ }
143
+ /** Change the instance's dispatch table (box `dispatch`, a kernel operation): add or remove the mailbox row (sender, box) → handler. No reply. */
144
+ dispatch(a) {
145
+ return this.send(BOX.dispatch, dispatchBody(a));
146
+ }
147
+ /**
148
+ * Chat with the instance (box `chat`). Continues the conversation in
149
+ * conversation.json (replyTo = the instance's last reply, tree defaults to its tree)
150
+ * unless `fresh`.
151
+ */
152
+ chat(args) {
153
+ const body = chatBody(args, this.conversation());
154
+ return this.send(BOX.chat, body, {
155
+ text: args.text,
156
+ ...(body.tree && { tree: body.tree.toString() }),
157
+ ...(body.replyTo && { replyTo: body.replyTo.toString() }),
158
+ });
159
+ }
160
+ conversation() {
161
+ return loadConversation(this.cfg.stateDir);
162
+ }
163
+ /**
164
+ * Read (and by default acknowledge) David's boxes, `results` then `chat`.
165
+ * The newest well-formed, verified `chat` reply from the instance becomes
166
+ * the conversation state.
167
+ */
168
+ async inbox(opts = {}) {
169
+ const out = [];
170
+ for (const box of INBOX) {
171
+ const msgs = await this.mailbox.list(box);
172
+ for (const m of msgs) {
173
+ const r = { box, messageId: m.messageId, cid: m.messageId, sender: m.sender, verified: true };
174
+ if (m.value && typeof m.value === "object" && !Array.isArray(m.value) && !(m.value instanceof Uint8Array))
175
+ r.body = m.value;
176
+ else
177
+ r.error = "the body is not a record";
178
+ out.push(r);
179
+ }
180
+ if ((opts.ack ?? true) && msgs.length)
181
+ await this.mailbox.ack(msgs.map((m) => m.messageId));
182
+ }
183
+ const prev = this.conversation();
184
+ let newest;
185
+ for (const r of out) {
186
+ if (!isInstanceReply(r, this.cfg.instance.identityKey))
187
+ continue;
188
+ try {
189
+ const c = conversationFrom(r.cid, parseReply(r.body), r.created, newest ?? prev);
190
+ if (!newest || c.at >= newest.at)
191
+ newest = c;
192
+ }
193
+ catch (e) {
194
+ r.error = e.message;
195
+ }
196
+ }
197
+ if (newest)
198
+ saveConversation(this.cfg.stateDir, newest);
199
+ return out;
200
+ }
201
+ /**
202
+ * Poll the inbox every `intervalMs` until a message in one of `want`'s boxes
203
+ * answers (replyTo) the paired CID; every message read goes to `onResult`.
204
+ * Resolves the matching result, or undefined at the deadline.
205
+ */
206
+ async waitFor(want, opts) {
207
+ const deadline = Date.now() + opts.timeoutMs;
208
+ for (;;) {
209
+ let hit;
210
+ for (const r of await this.inbox({ ack: opts.ack })) {
211
+ opts.onResult?.(r);
212
+ if (!hit && r.body && want.some((w) => w.box === r.box && String(r.body.replyTo) === w.replyTo))
213
+ hit = r;
214
+ }
215
+ if (hit)
216
+ return hit;
217
+ if (Date.now() > deadline)
218
+ return undefined;
219
+ await new Promise((res) => setTimeout(res, opts.intervalMs ?? 1000));
220
+ }
221
+ }
222
+ lastSent(box) {
223
+ let lines;
224
+ try {
225
+ lines = readFileSync(join(this.cfg.stateDir, "sent.jsonl"), "utf8").trim().split("\n");
226
+ }
227
+ catch {
228
+ return undefined;
229
+ }
230
+ for (let i = lines.length - 1; i >= 0; i--) {
231
+ const s = JSON.parse(lines[i]);
232
+ if (!box || s.box === box)
233
+ return s;
234
+ }
235
+ return undefined;
236
+ }
237
+ }
@@ -0,0 +1,18 @@
1
+ export interface ClientConfig {
2
+ home: string;
3
+ /** Owner (David) wallet-api: `1sat serve wallet-api` on its own port. */
4
+ walletUrl: string;
5
+ originator: string;
6
+ /** The instance's own URL (its front door): http://<handle>.localhost:8100 or http://127.0.0.1:8100/@<handle>. */
7
+ instanceUrl: string;
8
+ /** David's mailbox: the mailbox instance his instances deliver to (#40). */
9
+ mailboxUrl: string;
10
+ instance: {
11
+ identityKey: string;
12
+ handle: string;
13
+ domain: string;
14
+ };
15
+ /** Local client state: sent envelope cids. */
16
+ stateDir: string;
17
+ }
18
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): ClientConfig;
@@ -0,0 +1,31 @@
1
+ // Where the client finds things on David's machine. Everything is a file under
2
+ // ~/.skein (or $SKEIN_HOME) written by scripts/host/*.sh; env vars override.
3
+ // No key is ever read here: the client signs through the owner's wallet-api.
4
+ import { readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ function read(file) {
8
+ try {
9
+ return readFileSync(file, "utf8").trim() || undefined;
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ }
15
+ export function loadConfig(env = process.env) {
16
+ const home = env.SKEIN_HOME ?? join(homedir(), ".skein");
17
+ const identityKey = env.SKEIN_INSTANCE_IDENTITY ?? read(join(home, "instance.identity"));
18
+ if (!identityKey)
19
+ throw new Error(`no instance identity: write \`skein-host identity <handle>\` to ${join(home, "instance.identity")}, or set SKEIN_INSTANCE_IDENTITY`);
20
+ const [handle, domain] = (env.SKEIN_INSTANCE_HANDLE ?? "skein@localhost").split("@");
21
+ const host = (env.SKEIN_HOST_URL ?? "http://127.0.0.1:8100").replace(/\/+$/, "");
22
+ return {
23
+ home,
24
+ walletUrl: env.SKEIN_OWNER_WALLET ?? "http://127.0.0.1:3322",
25
+ originator: env.SKEIN_ORIGINATOR ?? "skein-client",
26
+ instanceUrl: env.SKEIN_INSTANCE_URL ?? `${host}/@${handle}`,
27
+ mailboxUrl: env.SKEIN_MAILBOX_URL ?? read(join(home, "mailbox.url")) ?? `${host}/@${env.SKEIN_MAILBOX_HANDLE ?? "david"}`,
28
+ instance: { identityKey, handle: handle, domain: domain ?? "localhost" },
29
+ stateDir: join(home, "client"),
30
+ };
31
+ }
@@ -0,0 +1,62 @@
1
+ import { CID } from "multiformats/cid";
2
+ export interface Conversation {
3
+ /** CID of the last `chat` reply received from the instance. */
4
+ reply: string;
5
+ thread?: string;
6
+ tree?: string;
7
+ at: string;
8
+ }
9
+ export interface ChatBody {
10
+ text: string;
11
+ tree?: CID;
12
+ model?: string;
13
+ replyTo?: CID;
14
+ }
15
+ /** The instance's `chat` reply: its answer at the end of a turn names its tree and thread; a mid-turn message to David may not. */
16
+ export interface ReplyBody {
17
+ text: string;
18
+ page?: string;
19
+ tree?: CID;
20
+ thread?: CID;
21
+ replyTo: CID;
22
+ }
23
+ export declare const conversationFile: (stateDir: string) => string;
24
+ export declare function loadConversation(stateDir: string): Conversation | undefined;
25
+ /** A stored conversation, or undefined. Takes the older `say` field for `reply`. */
26
+ export declare function asConversation(x: unknown): Conversation | undefined;
27
+ export declare function saveConversation(stateDir: string, c: Conversation): void;
28
+ /**
29
+ * The `chat` body. Continuing (a conversation exists and not `fresh`): replyTo
30
+ * is the last reply, and the tree defaults to that reply's tree. New: neither.
31
+ */
32
+ export declare function chatBody(opts: {
33
+ text: string;
34
+ tree?: string;
35
+ model?: string;
36
+ fresh?: boolean;
37
+ }, conv?: Conversation): ChatBody;
38
+ /** Check a decoded `chat` reply body; throws on a malformed one (or a chat that is not a reply). */
39
+ export declare function parseReply(body: Record<string, unknown> | undefined): ReplyBody;
40
+ /**
41
+ * Conversation state after receiving the reply envelope `cid`. A reply that
42
+ * names no tree (a message mid-turn) keeps the tree of `prev`, the state before.
43
+ */
44
+ export declare function conversationFrom(cid: string, r: ReplyBody, at?: string, prev?: Conversation): Conversation;
45
+ export declare const short: (cid: string) => string;
46
+ export type ReplCmd = {
47
+ kind: "chat";
48
+ text: string;
49
+ } | {
50
+ kind: "new";
51
+ } | {
52
+ kind: "tree";
53
+ cid: string;
54
+ } | {
55
+ kind: "quit";
56
+ } | {
57
+ kind: "empty";
58
+ } | {
59
+ kind: "error";
60
+ message: string;
61
+ };
62
+ export declare function parseReplLine(line: string): ReplCmd;
@@ -0,0 +1,113 @@
1
+ // Chat state on David's machine: the last `chat` reply the instance sent
2
+ // (~/.skein/client/conversation.json). `chat` continues from it by setting
3
+ // `replyTo` to that envelope's CID; `--new` (or no file) starts a fresh
4
+ // conversation. Both directions are box `chat`: David's chat to the instance,
5
+ // the instance's reply (`replyTo` set) to David (see src/client/README.md).
6
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { CID } from "multiformats/cid";
9
+ export const conversationFile = (stateDir) => join(stateDir, "conversation.json");
10
+ export function loadConversation(stateDir) {
11
+ try {
12
+ return asConversation(JSON.parse(readFileSync(conversationFile(stateDir), "utf8")));
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ /** A stored conversation, or undefined. Takes the older `say` field for `reply`. */
19
+ export function asConversation(x) {
20
+ const c = x;
21
+ if (!c || typeof c !== "object")
22
+ return undefined;
23
+ const reply = typeof c.reply === "string" ? c.reply : typeof c.say === "string" ? c.say : undefined;
24
+ if (reply === undefined)
25
+ return undefined;
26
+ const { say: _, ...rest } = c;
27
+ return { ...rest, reply, at: typeof c.at === "string" ? c.at : "" };
28
+ }
29
+ export function saveConversation(stateDir, c) {
30
+ mkdirSync(stateDir, { recursive: true });
31
+ writeFileSync(conversationFile(stateDir), JSON.stringify(c, null, 2) + "\n");
32
+ }
33
+ /**
34
+ * The `chat` body. Continuing (a conversation exists and not `fresh`): replyTo
35
+ * is the last reply, and the tree defaults to that reply's tree. New: neither.
36
+ */
37
+ export function chatBody(opts, conv) {
38
+ if (!opts.text.trim())
39
+ throw new Error("chat: empty text");
40
+ const b = { text: opts.text };
41
+ const cont = !opts.fresh && conv ? conv : undefined;
42
+ const tree = opts.tree ?? cont?.tree;
43
+ if (tree !== undefined)
44
+ b.tree = CID.parse(tree);
45
+ if (opts.model !== undefined)
46
+ b.model = opts.model;
47
+ if (cont)
48
+ b.replyTo = CID.parse(cont.reply);
49
+ return b;
50
+ }
51
+ const cidOf = (v) => CID.asCID(v) ?? undefined;
52
+ /** Check a decoded `chat` reply body; throws on a malformed one (or a chat that is not a reply). */
53
+ export function parseReply(body) {
54
+ if (!body || typeof body.text !== "string")
55
+ throw new Error("chat: no text");
56
+ const replyTo = cidOf(body.replyTo);
57
+ if (!replyTo)
58
+ throw new Error("chat: not a reply (replyTo is not a CID)");
59
+ const s = { text: body.text, replyTo };
60
+ if (body.thread !== undefined) {
61
+ const thread = cidOf(body.thread);
62
+ if (!thread)
63
+ throw new Error("chat: thread is not a CID");
64
+ s.thread = thread;
65
+ }
66
+ if (body.page !== undefined) {
67
+ if (typeof body.page !== "string")
68
+ throw new Error("chat: page is not a string");
69
+ s.page = body.page;
70
+ }
71
+ if (body.tree !== undefined) {
72
+ const tree = cidOf(body.tree);
73
+ if (!tree)
74
+ throw new Error("chat: tree is not a CID");
75
+ s.tree = tree;
76
+ }
77
+ return s;
78
+ }
79
+ /**
80
+ * Conversation state after receiving the reply envelope `cid`. A reply that
81
+ * names no tree (a message mid-turn) keeps the tree of `prev`, the state before.
82
+ */
83
+ export function conversationFrom(cid, r, at = new Date().toISOString(), prev) {
84
+ const tree = r.tree?.toString() ?? prev?.tree;
85
+ const thread = r.thread?.toString() ?? prev?.thread;
86
+ return { reply: cid, ...(thread && { thread }), ...(tree && { tree }), at };
87
+ }
88
+ export const short = (cid) => (cid.length > 16 ? `${cid.slice(0, 6)}…${cid.slice(-6)}` : cid);
89
+ export function parseReplLine(line) {
90
+ const t = line.trim();
91
+ if (!t)
92
+ return { kind: "empty" };
93
+ if (!t.startsWith("/"))
94
+ return { kind: "chat", text: t };
95
+ const [cmd, ...args] = t.split(/\s+/);
96
+ switch (cmd) {
97
+ case "/new": return args.length ? { kind: "error", message: "/new takes no arguments" } : { kind: "new" };
98
+ case "/quit":
99
+ case "/exit": return { kind: "quit" };
100
+ case "/tree": {
101
+ if (args.length !== 1)
102
+ return { kind: "error", message: "usage: /tree <cid>" };
103
+ try {
104
+ CID.parse(args[0]);
105
+ }
106
+ catch {
107
+ return { kind: "error", message: `not a CID: ${args[0]}` };
108
+ }
109
+ return { kind: "tree", cid: args[0] };
110
+ }
111
+ default: return { kind: "error", message: `unknown command ${cmd} (/new, /tree <cid>, /quit)` };
112
+ }
113
+ }