@aikdna/kdna-studio-cli 0.10.2 → 0.13.0-rc.components.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.
@@ -0,0 +1,507 @@
1
+ 'use strict';
2
+
3
+ // Terminal I/O and Studio API orchestration. Public semantics stay upstream.
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+ const net = require('node:net');
8
+ const os = require('node:os');
9
+ const tty = require('node:tty');
10
+ const { TextDecoder } = require('node:util');
11
+ const { createRequire } = require('node:module');
12
+ const bindings = require('./public-bindings.json');
13
+ const LIMIT = 1024 * 1024;
14
+ const PASSWORD_LIMIT = 64 * 1024;
15
+ function fail(code) { throw Object.assign(new Error(code), { code }); }
16
+ function record(value, keys) {
17
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail('CLI_INPUT_INVALID');
18
+ for (const key of Object.keys(value)) if (!keys.includes(key)) fail('CLI_FIELD_FORBIDDEN');
19
+ }
20
+ function text(value) {
21
+ if (typeof value !== 'string' || !value.trim() || !value.isWellFormed() || Buffer.byteLength(value) > LIMIT) fail('CLI_TEXT_INVALID');
22
+ return value;
23
+ }
24
+ function json(value) { return JSON.stringify(value) + '\n'; }
25
+ function emit(value) { process.stdout.write(json(value)); }
26
+ function parse(raw) { try { return JSON.parse(raw); } catch { fail('CLI_JSON_INVALID'); } }
27
+ function decode(raw) { try { return new TextDecoder('utf-8', { fatal: true }).decode(raw); } catch { fail('CLI_UTF8_INVALID'); } }
28
+
29
+ async function* lines(stream) {
30
+ let pending = Buffer.alloc(0);
31
+ for await (const chunk of stream) {
32
+ pending = Buffer.concat([pending, Buffer.from(chunk)]);
33
+ let end;
34
+ while ((end = pending.indexOf(10)) !== -1) {
35
+ if (end > LIMIT) fail('CLI_FRAME_LIMIT');
36
+ const raw = pending.subarray(0, end); pending = pending.subarray(end + 1);
37
+ yield decode(raw);
38
+ }
39
+ if (pending.length > LIMIT) fail('CLI_FRAME_LIMIT');
40
+ }
41
+ if (pending.length) fail('CLI_FRAME_INCOMPLETE');
42
+ }
43
+
44
+ function agentInput(stream, onStop, eofCode) {
45
+ let pending = Buffer.alloc(0);
46
+ let queued = null;
47
+ let waiter = null;
48
+ let failure = null;
49
+ let completed = false;
50
+ let closing = false;
51
+ const error = code => Object.assign(new Error(code), { code });
52
+ function stop(reason) {
53
+ if (failure || completed || closing) return;
54
+ failure = reason; pending = Buffer.alloc(0); queued = null;
55
+ if (waiter) { const current = waiter; waiter = null; current.reject(reason); }
56
+ stream.destroy();
57
+ onStop();
58
+ }
59
+ function data(chunk) {
60
+ if (failure || completed || closing) return;
61
+ try {
62
+ pending = Buffer.concat([pending, Buffer.from(chunk)]);
63
+ let end;
64
+ while ((end = pending.indexOf(10)) !== -1) {
65
+ if (end > LIMIT) fail('CLI_FRAME_LIMIT');
66
+ const value = decode(pending.subarray(0, end));
67
+ pending = pending.subarray(end + 1);
68
+ // One operation is executing; retain at most one next frame. Keeping
69
+ // this reader active lets native EOF interrupt a pending human read.
70
+ if (waiter) { const current = waiter; waiter = null; current.resolve({ value, done: false }); }
71
+ else if (queued === null) queued = value;
72
+ else fail('CLI_INPUT_QUEUE_LIMIT');
73
+ }
74
+ if (pending.length > LIMIT) fail('CLI_FRAME_LIMIT');
75
+ } catch (reason) { stop(reason); }
76
+ }
77
+ const end = () => stop(error(pending.length ? 'CLI_FRAME_INCOMPLETE' : eofCode()));
78
+ const closed = () => stop(error('CLI_AGENT_CHANNEL_CLOSED'));
79
+ stream.on('data', data); stream.on('end', end);
80
+ stream.on('error', stop); stream.on('close', closed);
81
+ return {
82
+ check() { if (failure) throw failure; },
83
+ next() {
84
+ if (failure) return Promise.reject(failure);
85
+ if (queued !== null) { const value = queued; queued = null; return Promise.resolve({ value, done: false }); }
86
+ if (waiter || completed || closing) return Promise.reject(error('CLI_AGENT_CHANNEL_CLOSED'));
87
+ return new Promise((resolve, reject) => { waiter = { resolve, reject }; });
88
+ },
89
+ complete() { completed = true; pending = Buffer.alloc(0); queued = null; },
90
+ async close() {
91
+ closing = true; pending = Buffer.alloc(0); queued = null;
92
+ if (waiter) { const current = waiter; waiter = null; current.reject(error('CLI_AGENT_CHANNEL_CLOSED')); }
93
+ if (!stream.closed) await new Promise(resolve => { stream.once('close', resolve); stream.destroy(); });
94
+ stream.off('data', data); stream.off('end', end);
95
+ stream.off('error', stop); stream.off('close', closed);
96
+ },
97
+ };
98
+ }
99
+
100
+ function capture(file, limit = LIMIT) {
101
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
102
+ try {
103
+ const info = fs.fstatSync(fd);
104
+ if (!info.isFile() || info.size > limit) fail('CLI_FILE_INVALID');
105
+ const buffer = Buffer.alloc(limit + 1);
106
+ let size = 0;
107
+ for (;;) {
108
+ const count = fs.readSync(fd, buffer, size, buffer.length - size, null); size += count;
109
+ if (size > limit) fail('CLI_FILE_LIMIT');
110
+ if (!count) return buffer.subarray(0, size);
111
+ }
112
+ } finally { fs.closeSync(fd); }
113
+ }
114
+
115
+ function dependencies() {
116
+ // Verify the declared exact extracted dependency contents before executing them.
117
+ // These local checks do not authenticate an archive origin or a provider.
118
+ const roots = new Map();
119
+ for (const archive of bindings.archives) {
120
+ let publicPath;
121
+ try { publicPath = require.resolve(archive.name); }
122
+ catch { fail('CLI_DEPENDENCY_MISSING'); }
123
+ const suffix = path.sep + archive.publicEntry.split('/').join(path.sep);
124
+ if (!publicPath.endsWith(suffix)) fail('CLI_DEPENDENCY_PATHSET_MISMATCH');
125
+ const root = publicPath.slice(0, -suffix.length);
126
+ const packagePath = path.join(root, 'package.json');
127
+ const rootInfo = fs.lstatSync(root);
128
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) fail('CLI_DEPENDENCY_PATHSET_MISMATCH');
129
+ const metadata = parse(decode(capture(packagePath)));
130
+ if (metadata.name !== archive.name || metadata.version !== archive.version) fail('CLI_DEPENDENCY_MISMATCH');
131
+ const expected = new Map(archive.files.map(item => [item.path, item]));
132
+ const actual = [];
133
+ function walk(directory, prefix = '') {
134
+ for (const name of fs.readdirSync(directory).sort()) {
135
+ const relative = prefix ? prefix + '/' + name : name;
136
+ const file = path.join(directory, name);
137
+ const info = fs.lstatSync(file);
138
+ if (info.isSymbolicLink()) fail('CLI_DEPENDENCY_PATHSET_MISMATCH');
139
+ if (info.isDirectory()) walk(file, relative);
140
+ else if (info.isFile()) {
141
+ const entry = expected.get(relative);
142
+ if (!entry || info.size !== entry.bytes) fail('CLI_DEPENDENCY_PATHSET_MISMATCH');
143
+ const bytes = capture(file, entry.bytes);
144
+ if (crypto.createHash('sha256').update(bytes).digest('hex') !== entry.sha256) fail('CLI_DEPENDENCY_CONTENT_MISMATCH');
145
+ actual.push(relative);
146
+ } else fail('CLI_DEPENDENCY_PATHSET_MISMATCH');
147
+ }
148
+ }
149
+ walk(root);
150
+ if (actual.length !== expected.size) fail('CLI_DEPENDENCY_PATHSET_MISMATCH');
151
+ roots.set(archive.name, { root, packagePath, publicPath, scoped: createRequire(packagePath) });
152
+ }
153
+ for (const archive of bindings.archives) {
154
+ const { scoped } = roots.get(archive.name);
155
+ for (const name of new Set([...Object.keys(archive.dependencies), ...Object.keys(archive.peerDependencies)])) {
156
+ const bound = roots.get(name);
157
+ if (!bound || scoped.resolve(name) !== bound.publicPath) fail('CLI_DEPENDENCY_GRAPH_MISMATCH');
158
+ }
159
+ for (const name of Object.keys(archive.optionalDependencies)) {
160
+ if (roots.has(name)) continue;
161
+ // Package managers install optional dependencies by default, so a
162
+ // consumer that simply ran `npm install` has cbor-x's optional native
163
+ // accelerator next to this graph. The CLI never requires it: every CBOR
164
+ // operation goes through the pure-JS entry in the bound packages. Its
165
+ // presence must therefore not turn a working install into a refusal, and
166
+ // an injected module is never executed because nothing resolves it for
167
+ // use. Resolution is probed only to surface a broken optional entry.
168
+ try { scoped.resolve(name); }
169
+ catch (error) { if (error.code === 'MODULE_NOT_FOUND') continue; throw error; }
170
+ }
171
+ }
172
+ for (const [name, version] of Object.entries(bindings.packages)) {
173
+ if (!roots.has(name) || require(name + '/package.json').version !== version) fail('CLI_DEPENDENCY_MISMATCH');
174
+ }
175
+ const studio = require('@aikdna/kdna-studio-core');
176
+ const core = require('@aikdna/kdna-core');
177
+ const read = require('@aikdna/kdna-read');
178
+ const corePath = require.resolve('@aikdna/kdna-core');
179
+ for (const name of Object.keys(bindings.packages)) {
180
+ const scoped = createRequire(require.resolve(name));
181
+ if (scoped.resolve('@aikdna/kdna-core') !== corePath || scoped('@aikdna/kdna-core') !== core) fail('CLI_MULTIPLE_CORE_INSTANCES');
182
+ }
183
+ if (Object.keys(studio).sort().join(',') !== 'createSession,verifyCreationEvidence') fail('CLI_STUDIO_SURFACE_MISMATCH');
184
+ let protection;
185
+ try { protection = require('@aikdna/kdna-studio-core/protected-export'); }
186
+ catch { fail('CLI_PROTECTION_UNAVAILABLE'); }
187
+ if (!protection || typeof protection.protectExportedContainer !== 'function') fail('CLI_PROTECTION_UNAVAILABLE');
188
+ return { studio, core, read, protectExportedContainer: protection.protectExportedContainer };
189
+ }
190
+
191
+ function options(argv) {
192
+ const command = argv[0] || 'help';
193
+ if (!['help', '--help', '--version', 'session', 'verify', 'read'].includes(command)) fail('CLI_COMMAND_UNSUPPORTED');
194
+ const opts = { command, materials: [] };
195
+ const allowed = command === 'session' ? ['--out', '--human-fd', '--agent-adoption-fd', '--delegation-record', '--text', '--interview', '--synthetic-fixture', '--password-fd'] :
196
+ ['verify', 'read'].includes(command) ? ['--bundle', ...(command === 'read' ? ['--allow-read', '--budget', '--judgment'] : [])] : [];
197
+ const seen = new Set();
198
+ for (let i = 1; i < argv.length; i++) {
199
+ const flag = argv[i];
200
+ if (flag === '--password' || flag.startsWith('--password=')) fail('CLI_PASSWORD_ARGV_FORBIDDEN');
201
+ if (!allowed.includes(flag)) fail('CLI_OPTION_UNSUPPORTED');
202
+ if (!['--text', '--interview'].includes(flag) && seen.has(flag)) fail('CLI_OPTION_REPEATED');
203
+ seen.add(flag);
204
+ if (['--allow-read', '--synthetic-fixture'].includes(flag)) { opts[flag] = true; continue; }
205
+ const value = argv[++i];
206
+ if (!value || value.startsWith('--')) fail('CLI_OPTION_VALUE_REQUIRED');
207
+ if (['--text', '--interview'].includes(flag)) opts.materials.push({ kind: flag.slice(2), file: value });
208
+ else opts[flag] = value;
209
+ }
210
+ if (command === 'session' && !opts['--out']) fail('CLI_OUTPUT_REQUIRED');
211
+ if (['verify', 'read'].includes(command) && !opts['--bundle']) fail('CLI_BUNDLE_REQUIRED');
212
+ if (opts['--human-fd'] && (!/^\d+$/.test(opts['--human-fd']) || Number(opts['--human-fd']) < 3)) fail('CLI_HUMAN_CHANNEL_INVALID');
213
+ if (opts['--agent-adoption-fd'] && (!/^\d+$/.test(opts['--agent-adoption-fd']) || Number(opts['--agent-adoption-fd']) < 3)) fail('CLI_AGENT_ADOPTION_CHANNEL_INVALID');
214
+ if (Boolean(opts['--agent-adoption-fd']) !== Boolean(opts['--delegation-record']) || (opts['--agent-adoption-fd'] && opts['--human-fd'])) fail('CLI_ADOPTION_OPTIONS_INVALID');
215
+ if (opts['--judgment'] && (!/^[1-9]\d*$/.test(opts['--judgment']) || !Number.isSafeInteger(Number(opts['--judgment'])))) fail('CLI_SELECTION_INVALID');
216
+ if (opts['--password-fd'] && (!/^\d+$/.test(opts['--password-fd']) || Number(opts['--password-fd']) < 3)) fail('CLI_PASSWORD_FD_INVALID');
217
+ // Compare the normalized descriptor numbers: `03` and `3` name the same fd,
218
+ // and a leading zero must not slip past the duplication check.
219
+ if (opts['--password-fd']) {
220
+ const descriptor = Number(opts['--password-fd']);
221
+ const human = opts['--human-fd'] === undefined ? null : Number(opts['--human-fd']);
222
+ const adoption = opts['--agent-adoption-fd'] === undefined ? null : Number(opts['--agent-adoption-fd']);
223
+ if (descriptor === human || descriptor === adoption) fail('CLI_PASSWORD_FD_INVALID');
224
+ }
225
+ return opts;
226
+ }
227
+
228
+ function humanChannel(opts) {
229
+ let fd;
230
+ let input;
231
+ try {
232
+ fd = opts['--agent-adoption-fd'] ? Number(opts['--agent-adoption-fd']) : opts['--human-fd'] ? Number(opts['--human-fd']) : fs.openSync('/dev/tty', 'r+');
233
+ const info = fs.fstatSync(fd);
234
+ // The CLI child owns its inherited read endpoint. Native stream handles
235
+ // cancel reads on destroy; fs.ReadStream can retain a blocking pipe read.
236
+ if (tty.isatty(fd)) input = new tty.ReadStream(fd, { highWaterMark: 4096 });
237
+ else if (info.isFIFO() || info.isSocket()) input = new net.Socket({ fd, readable: true, writable: false, highWaterMark: 4096 });
238
+ else fail('CLI_HUMAN_CHANNEL_REQUIRED');
239
+ } catch (error) {
240
+ if (fd !== undefined && !input) {
241
+ try { fs.closeSync(fd); } catch (closeError) { if (closeError.code !== 'EBADF') throw closeError; }
242
+ }
243
+ fail('CLI_HUMAN_CHANNEL_REQUIRED');
244
+ }
245
+ const iterator = lines(input)[Symbol.asyncIterator]();
246
+ return {
247
+ cancel() { input.destroy(); },
248
+ async receive(prompt) {
249
+ const visible = prompt.replace(/[\u0000-\u0008\u000b-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g,
250
+ char => '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0'));
251
+ process.stderr.write(visible + '\n');
252
+ const next = await iterator.next();
253
+ if (next.done) fail('CLI_HUMAN_CHANNEL_CLOSED');
254
+ return text(next.value);
255
+ },
256
+ async close() {
257
+ if (input.closed) return;
258
+ await new Promise(resolve => {
259
+ input.once('close', resolve);
260
+ input.destroy();
261
+ });
262
+ },
263
+ };
264
+ }
265
+
266
+ function humanReview(review) {
267
+ return (review.kind === 'delegated_agent_editorial' ? 'Authorized Agent editorial review (identity not verified):' : 'Human-declared review (identity not verified):') + '\n' + JSON.stringify(review, null, 2);
268
+ }
269
+
270
+ function readPasswordFd(fd) {
271
+ // One-shot bounded secret read from a dedicated pipe fd (never argv, never
272
+ // the agent stream). The buffer is zeroized on every exit path.
273
+ const storage = Buffer.alloc(PASSWORD_LIMIT + 1);
274
+ let length = 0;
275
+ try {
276
+ try { fs.fstatSync(fd); } catch { fail('CLI_PASSWORD_UNAVAILABLE'); }
277
+ if (tty.isatty(fd)) fail('CLI_PASSWORD_TTY_FORBIDDEN');
278
+ try {
279
+ while (length < storage.length) {
280
+ const count = fs.readSync(fd, storage, length, storage.length - length, null);
281
+ if (count === 0) break;
282
+ length += count;
283
+ }
284
+ } finally { try { fs.closeSync(fd); } catch (error) { if (error.code !== 'EBADF') throw error; } }
285
+ if (length === 0) fail('CLI_PASSWORD_UNAVAILABLE');
286
+ if (length > PASSWORD_LIMIT) fail('CLI_PASSWORD_TOO_LARGE');
287
+ let decoded;
288
+ try { decoded = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(storage.subarray(0, length)); }
289
+ catch { fail('CLI_PASSWORD_ENCODING_INVALID'); }
290
+ if (decoded.endsWith('\r\n')) decoded = decoded.slice(0, -2);
291
+ else if (decoded.endsWith('\n')) decoded = decoded.slice(0, -1);
292
+ if (!decoded.length) fail('CLI_PASSWORD_UNAVAILABLE');
293
+ return decoded;
294
+ } finally { storage.fill(0); }
295
+ }
296
+
297
+ function displayRecoveryCode(code) {
298
+ // Display-once belongs to this Host: the code is shown here and nowhere else.
299
+ process.stderr.write('Recovery code (shown exactly once; store it now, it cannot be retrieved later):\n' + code + '\n');
300
+ }
301
+
302
+ async function writeExport(session, destination, password, protectExportedContainer) {
303
+ const parent = fs.realpathSync(path.dirname(path.resolve(destination)));
304
+ const target = path.join(parent, path.basename(destination));
305
+ fs.mkdirSync(target, { mode: 0o700 }); // exclusive reservation, never replace an existing bundle
306
+ const created = [];
307
+ let isolation = null;
308
+ // Create the file exclusively, then register it before writing a byte. A
309
+ // write that fails part-way (EFBIG, ENOSPC, EIO) therefore still leaves a
310
+ // registered path, so the rollback removes the incomplete file, the
311
+ // directory removal succeeds, and the original error reaches the caller
312
+ // instead of being masked by ENOTEMPTY. Registration happens only after
313
+ // this invocation really created the file, so rollback never unlinks a
314
+ // path somebody else made.
315
+ function trackedWrite(name, bytes) {
316
+ const file = path.join(target, name);
317
+ const descriptor = fs.openSync(file, 'wx', 0o400);
318
+ created.push(file);
319
+ try { fs.writeFileSync(descriptor, bytes); } finally { fs.closeSync(descriptor); }
320
+ return file;
321
+ }
322
+ try {
323
+ const result = session.exportAsset();
324
+ let bytes = result.bytes;
325
+ let verification;
326
+ let protection = null;
327
+ let recoveryCode = null;
328
+ if (password) {
329
+ // The saved-byte completion binds the unprotected bytes in an isolated
330
+ // area only: plaintext never lands at the delivery position, and the
331
+ // protected container is a deterministic post-verification derivative.
332
+ isolation = fs.mkdtempSync(path.join(os.tmpdir(), 'kdna-studio-protected-export-'));
333
+ const isolatedAsset = path.join(isolation, 'asset.kdna');
334
+ fs.writeFileSync(isolatedAsset, result.bytes, { flag: 'wx', mode: 0o400 });
335
+ const readback = capture(isolatedAsset, 16 * LIMIT);
336
+ verification = session.completeSave(readback);
337
+ const produced = await protectExportedContainer(result.bytes, { password });
338
+ if (!produced || produced.status !== 'produced' || !(produced.bytes instanceof Uint8Array) ||
339
+ typeof produced.recoveryCode !== 'string' ||
340
+ !/^kdna-recover-(?:[0-9A-F]{4}-){15}[0-9A-F]{4}$/.test(produced.recoveryCode)) fail('CLI_PROTECTION_FAILED');
341
+ bytes = Buffer.from(produced.bytes);
342
+ recoveryCode = produced.recoveryCode;
343
+ protection = { profile: 'kdna.envelope.aead', slots: ['password', 'recovery'], verification_basis: 'pre_protection_plaintext' };
344
+ }
345
+ const files = [['asset.kdna', bytes], ['creation-evidence.json', Buffer.from(json(result.evidence))],
346
+ ['binding.json', Buffer.from(json(result.binding))]];
347
+ for (const [name, fileBytes] of files) {
348
+ trackedWrite(name, fileBytes);
349
+ }
350
+ if (!password) {
351
+ const readback = capture(path.join(target, 'asset.kdna'), 16 * LIMIT);
352
+ verification = session.completeSave(readback);
353
+ }
354
+ trackedWrite('verification.json', json(verification));
355
+ trackedWrite('complete.json', json({ kind: 'private-studio-export-bundle', version: 2, ...(protection ? { protection } : {}) }));
356
+ fs.chmodSync(target, 0o500);
357
+ if (recoveryCode) displayRecoveryCode(recoveryCode);
358
+ return { directory: target, binding: result.binding, verification };
359
+ } catch (error) {
360
+ // Roll back only what this invocation created, and never replace the
361
+ // original failure with a cleanup failure.
362
+ let cleaned = true;
363
+ try { fs.chmodSync(target, 0o700); } catch { /* the reservation may already be gone */ }
364
+ for (const file of created.reverse()) { try { fs.unlinkSync(file); } catch (cleanupError) { if (cleanupError.code !== 'ENOENT') cleaned = false; } }
365
+ try { fs.rmdirSync(target); } catch (cleanupError) { if (cleanupError.code !== 'ENOENT') cleaned = false; }
366
+ if (!cleaned) process.stderr.write('Residual export path was not fully removed: ' + target + '\n');
367
+ throw error;
368
+ } finally {
369
+ if (isolation) fs.rmSync(isolation, { recursive: true, force: true });
370
+ }
371
+ }
372
+
373
+ async function session(opts) {
374
+ const { studio, protectExportedContainer } = dependencies();
375
+ const kind = opts['--agent-adoption-fd'] ? 'delegated_agent_editorial' : 'human_claim_unverified';
376
+ const authorization = kind === 'delegated_agent_editorial' ? parse(decode(capture(opts['--delegation-record']))) : null;
377
+ if (authorization) {record(authorization,['coordinate','statement']);text(authorization.coordinate);text(authorization.statement);}
378
+ const human = humanChannel(opts);
379
+ let phase = 'request';
380
+ const input = agentInput(process.stdin, () => human.cancel(),
381
+ () => phase === 'interpret' ? 'CLI_AGENT_CHANNEL_CLOSED' : 'CLI_SESSION_UNEXPORTED');
382
+ const seen = new Set();
383
+ function request(raw, op) {
384
+ input.check();
385
+ const value = parse(raw); record(value, ['id', 'op', 'data']); text(value.id); text(value.op);
386
+ if (seen.has(value.id)) fail('CLI_REQUEST_REPLAY');
387
+ seen.add(value.id);
388
+ if (op && value.op !== op) fail('CLI_REVIEW_IN_PROGRESS');
389
+ return value;
390
+ }
391
+ let currentReview = null;
392
+ const channelName = (kind === 'delegated_agent_editorial' ? 'terminal-authorized-agent:' : 'terminal-human:') + crypto.randomUUID();
393
+ const workspace = studio.createSession({
394
+ agent: { name: 'external-terminal-agent', version: '1' }, syntheticFixture: Boolean(opts['--synthetic-fixture']),
395
+ adoptionInput: { kind, channel: channelName, ...(authorization ? {authorization} : {}), async receive(review) {
396
+ currentReview = { ticket: crypto.randomUUID(), review };
397
+ const reply = await human.receive(humanReview(review));
398
+ input.check(); phase = 'interpret';
399
+ emit({ event: 'adoption_reply', replyTo: currentReview.ticket, text: reply, review });
400
+ return { id: 'message:' + crypto.randomUUID(), role: kind === 'delegated_agent_editorial' ? 'agent' : 'human', channel: channelName, review_id: review.review_id, text: reply };
401
+ } },
402
+ async interpretReply() {
403
+ const next = await input.next();
404
+ if (next.done) fail('CLI_AGENT_CHANNEL_CLOSED');
405
+ const value = request(next.value, 'interpret'); record(value.data, ['replyTo', 'kind', 'choices']);
406
+ if (value.data.replyTo !== currentReview.ticket) fail('CLI_REPLY_STALE');
407
+ const intent = { kind: value.data.kind };
408
+ if (Object.hasOwn(value.data,'choices')) intent.choices = value.data.choices;
409
+ return intent;
410
+ },
411
+ });
412
+ const operations = require('./component-operations').componentOperations(workspace, () => workspace.inspect().materials.map(m=>m.id));
413
+ try {
414
+ for (const item of opts.materials) {
415
+ if (!['.txt', '.md', '.text'].includes(path.extname(item.file).toLowerCase())) fail('CLI_MATERIAL_TYPE_UNSUPPORTED');
416
+ workspace.agent.recordMaterial({ kind: item.kind, title: path.basename(item.file), content: decode(capture(item.file)), coordinate: 'private-file:' + crypto.randomUUID() });
417
+ }
418
+ emit({ event: 'ready', workspace: workspace.inspect(), protocol: 'private-terminal-session-components-2' });
419
+ for (;;) {
420
+ phase = 'request';
421
+ const next = await input.next(); if (next.done) fail('CLI_SESSION_UNEXPORTED');
422
+ const value = request(next.value); let result;
423
+ switch (value.op) {
424
+ case 'brief': result = workspace.agent.setBrief(value.data); break;
425
+ case 'propose': result = operations.propose(value.data); break;
426
+ case 'revise': result = operations.revise(value.data); break;
427
+ case 'interview': {
428
+ record(value.data, ['title', 'question']); const question = text(value.data.question);
429
+ phase = 'human';
430
+ const answer = await human.receive(question);
431
+ input.check();
432
+ result = workspace.agent.recordMaterial({ kind: 'interview', title: value.data.title, content: question + '\n' + answer, coordinate: 'private-interview:' + crypto.randomUUID() }); break;
433
+ }
434
+ case 'review': record(value.data, []); phase = 'human'; result = await workspace.receiveAdoptionReply(); currentReview = null; break;
435
+ case 'preview': record(value.data, []); result = workspace.agent.compilePreview(); break;
436
+ case 'status': record(value.data, []); result = workspace.inspect(); break;
437
+ case 'export': {
438
+ record(value.data, []); input.check();
439
+ const password = opts['--password-fd'] ? readPasswordFd(Number(opts['--password-fd'])) : null;
440
+ result = await writeExport(workspace, opts['--out'], password, protectExportedContainer); input.complete(); break;
441
+ }
442
+ default: fail('CLI_OPERATION_UNSUPPORTED');
443
+ }
444
+ input.check();
445
+ emit({ id: value.id, status: 'ok', result });
446
+ if (value.op === 'export') return;
447
+ }
448
+ } catch (error) {
449
+ workspace.abort();
450
+ input.check();
451
+ throw error;
452
+ } finally { await human.close(); await input.close(); }
453
+ }
454
+
455
+ async function consume(opts) {
456
+ const { studio, core, read } = dependencies();
457
+ const { readNode } = require('@aikdna/kdna-read/node');
458
+ const { createTrustedReadControlProvider, createTrustedHostReadProvider } = require('@aikdna/kdna-read/embedding');
459
+ const control = createTrustedReadControlProvider(() => ({ admission_response_limit_bytes: 65536 }));
460
+ const request = { request_id: 'read:' + crypto.randomUUID(), tuple: bindings.tuple, mode: 'whole_asset', selection: null, handle: null,
461
+ budget_bytes: opts['--budget'] === undefined ? 65536 : Number(opts['--budget']) };
462
+ if (opts.command === 'read') {
463
+ const admission = read.admitReadRequest(request, control);
464
+ if (admission.channel !== 'admitted_request') { emit(admission); process.exitCode = 3; return; }
465
+ }
466
+ const directory = fs.realpathSync(opts['--bundle']);
467
+ const bytes = capture(path.join(directory, 'asset.kdna'), 16 * LIMIT);
468
+ const accepted = core.admitBytes(bytes);
469
+ if (accepted.status !== 'accepted') { emit(accepted); process.exitCode = 3; return; }
470
+ const complete = parse(decode(capture(path.join(directory, 'complete.json'))));
471
+ record(complete, ['kind', 'version']);
472
+ if (complete.kind !== 'private-studio-export-bundle' || complete.version !== 2) fail('CLI_BUNDLE_INCOMPLETE');
473
+ const evidence = parse(decode(capture(path.join(directory, 'creation-evidence.json'), 16 * LIMIT)));
474
+ const binding = parse(decode(capture(path.join(directory, 'binding.json'))));
475
+ const verified = studio.verifyCreationEvidence(bytes, evidence, binding);
476
+ if (verified.status !== 'consistent' || opts.command === 'verify') {
477
+ emit(verified); if (verified.status !== 'consistent') process.exitCode = 3; return;
478
+ }
479
+ if (opts['--judgment']) {
480
+ const item = accepted.snapshot.ir.catalog[Number(opts['--judgment']) - 1]; if (!item) fail('CLI_SELECTION_INVALID');
481
+ request.mode = 'exact_selection';
482
+ request.selection = { asset_id: accepted.snapshot.asset.asset_id, asset_version: accepted.snapshot.asset.asset_version, judgment_id: item.judgment_id };
483
+ }
484
+ let observations = 0;
485
+ const host = opts['--allow-read'] ? createTrustedHostReadProvider({ observe: ({ request: current, snapshot }) => {
486
+ const time = Date.now();
487
+ return { host_id: 'host:terminal-local-read', host_epoch: request.request_id, decision_id: 'decision:' + (++observations), request_id: current.request_id,
488
+ snapshot_id: snapshot.snapshot_id, A: snapshot.digests.A.observed, C: snapshot.digests.C.observed, scope: snapshot.ir.nodes.map(node => node.id),
489
+ issued_at: time, expires_at: time + 10000, current_ms: time, decision: 'allow', policy_id: 'policy:explicit-local-read-only' };
490
+ } }) : undefined;
491
+ const result = await readNode(bytes, request, control, host); emit(result);
492
+ if (result.channel !== 'read_envelope' || result.envelope.status !== 'ready') process.exitCode = 3;
493
+ }
494
+
495
+ async function main(argv) {
496
+ const opts = options(argv);
497
+ if (opts.command === '--version') { process.stdout.write(require('../package.json').version + '\n'); return; }
498
+ if (['help', '--help'].includes(opts.command)) {
499
+ process.stdout.write('kdna-studio session --out NEW_DIRECTORY [--text FILE] [--interview FILE] [--human-fd 3 | --agent-adoption-fd 3 --delegation-record FILE] [--password-fd N]\n' +
500
+ '--password-fd N exports the bundle as a password-protected container; the password is read once from pipe fd N (never argv or the agent stream), and the recovery code is displayed exactly once on stderr.\n' +
501
+ 'kdna-studio verify --bundle DIRECTORY\nkdna-studio read --bundle DIRECTORY [--allow-read] [--judgment 1] [--budget BYTES]\n' +
502
+ 'Agent operation JSONL uses stdin/stdout. Adoption replies use the terminal or a separate human/authorized-Agent fd, with explicit channel kind.\n' +
503
+ 'Ordinary and component-rich alternatives use the same current live creation graph. See docs/TERMINAL_AGENT_CREATION.md.\n'); return;
504
+ }
505
+ if (opts.command === 'session') await session(opts); else await consume(opts);
506
+ }
507
+ module.exports = { main };
package/src/ai/distill.js DELETED
@@ -1,77 +0,0 @@
1
- // AI Distillation — extract candidate judgment patterns from evidence
2
- const llm = require('../llm');
3
-
4
- const SYSTEM = `You are a domain judgment extraction engine. Your task is to analyze source materials and extract candidate cognitive patterns that can become axioms, ontologies, misunderstandings, or self-checks in a knowledge domain.
5
-
6
- For each candidate you find, provide:
7
- - candidate_id: unique identifier (kebab-case, e.g. "axiom-early-feedback")
8
- - type: one of [axiom, ontology, misunderstanding, self_check, framework, stance]
9
- - one_sentence: a single clear sentence capturing the judgment principle
10
- - full_statement: 2-4 sentence explanation with concrete examples
11
- - confidence: one of [high, medium, low]
12
- - evidence_ids: array of source file names that support this candidate
13
- - scope_fit: true (unless the candidate clearly belongs to a different domain)
14
-
15
- IMPORTANT:
16
- - Extract ONLY patterns that represent judgment, standards, or cognitive frameworks — not facts or summaries
17
- - Each candidate must be testable: you should be able to construct an eval case that verifies the agent applies it
18
- - Prefer quality over quantity: 5-15 strong candidates is better than 30 weak ones
19
- - Skip anything that is purely factual, biographical, or outside the domain's scope
20
-
21
- Return ONLY valid JSON: {"candidates": [...]}`;
22
-
23
- async function distill(config, evidence, target, options = {}) {
24
- const evidenceText = formatEvidence(evidence);
25
- const targetText = formatTarget(target);
26
-
27
- const userPrompt = [
28
- `## Distillation Target`,
29
- targetText,
30
- ``,
31
- `## Source Materials`,
32
- evidenceText,
33
- ``,
34
- `Extract candidate judgment patterns from these materials that are relevant to the target domain.`,
35
- ].join('\n');
36
-
37
- const result = await llm.chatWithJSON([
38
- { role: 'system', content: SYSTEM },
39
- { role: 'user', content: userPrompt },
40
- ], options);
41
-
42
- const candidates = (result.data && result.data.candidates) || [];
43
- return candidates.map(c => ({
44
- id: c.candidate_id || `cand_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
45
- one_sentence: c.one_sentence || '',
46
- full_statement: c.full_statement || '',
47
- suggested_card_type: c.type || 'axiom',
48
- supporting_evidence_ids: (c.evidence_ids || []).map(String),
49
- confidence: c.confidence || 'medium',
50
- candidate_status: 'proposed',
51
- scope_fit: c.scope_fit !== false,
52
- }));
53
- }
54
-
55
- function formatEvidence(evidence) {
56
- if (!evidence || evidence.length === 0) return '(no evidence loaded)';
57
- return evidence.map((e, i) => {
58
- const label = e.filename || e.name || `Source ${i + 1}`;
59
- const content = (e.content || '').slice(0, 8000);
60
- return `### ${label}\n\`\`\`\n${content}\n\`\`\``;
61
- }).join('\n\n');
62
- }
63
-
64
- function formatTarget(target) {
65
- if (!target) return '(no target declared)';
66
- const parts = [];
67
- if (target.domain_name) parts.push(`Domain: ${target.domain_name}`);
68
- if (target.category) parts.push(`Category: ${target.category}`);
69
- if (target.owner_scope) parts.push(`Scope: ${target.owner_scope}`);
70
- if (target.granularity) parts.push(`Granularity: ${target.granularity}`);
71
- if (target.task_scope) parts.push(`Task Scope: ${target.task_scope}`);
72
- if (target.include_areas) parts.push(`Include: ${target.include_areas.join(', ')}`);
73
- if (target.exclude_areas) parts.push(`Exclude: ${target.exclude_areas.join(', ')}`);
74
- return parts.join('\n');
75
- }
76
-
77
- module.exports = { distill, SYSTEM };