@ai-ecoverse/slicc-agent 6.3.2 → 6.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,28 @@
1
+ import { replicatedState } from "@earendil-works/chord";
2
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
3
+ import { configure } from "@earendil-works/pi-durable";
4
+ import { freeFolder, scoopId } from "../agents.js";
5
+ import { homeOf } from "../kernel/accounts.js";
6
+ import { quote } from "../kernel/env.js";
7
+ import { opfsFile } from "../opfs-pool.js";
8
+ import { TITLE_TURNS } from "../titles/extension.js";
9
+ import { place } from "./v6-place.js";
1
10
  import {
2
11
  checkLicks,
3
12
  checkManifest,
4
13
  checkTranscript
5
14
  } from "./v6-schema.js";
6
- import { ImportError, isLink, readEntry, zipEntries } from "./zip.js";
15
+ import { Writer } from "./v6-write.js";
16
+ import {
17
+ blobSource,
18
+ bytesSource,
19
+ ImportError,
20
+ isLink,
21
+ readEntry,
22
+ zipEntries
23
+ } from "./zip.js";
24
+ const V6_KIND = "slicc.v6";
25
+ const IMPORTS_DIR = "/var/lib/slicc/agent/imports";
7
26
  const MiB = 1024 ** 2;
8
27
  const LIMITS = {
9
28
  archive: 2048 * MiB,
@@ -78,15 +97,15 @@ function scan(list, archive) {
78
97
  throw new ImportError("the zip expands over 200:1, like a zip bomb");
79
98
  return entries;
80
99
  }
81
- async function openProfile(bytes) {
82
- if (bytes.length > LIMITS.archive) throw new ImportError("the zip is over 2 GiB");
83
- const list = zipEntries(bytes);
100
+ async function openProfile(source) {
101
+ if (source.size > LIMITS.archive) throw new ImportError("the zip is over 2 GiB");
102
+ const list = await zipEntries(source);
84
103
  if (list.length > LIMITS.entries)
85
104
  throw new ImportError(`the zip has over ${LIMITS.entries} entries`);
86
105
  if (list[0]?.name !== "manifest.json")
87
106
  throw new ImportError("manifest.json must be the first entry");
88
- const entries = scan(list, bytes.length);
89
- const manifest = checkManifest(json(await readEntry(bytes, list[0]), "manifest.json"));
107
+ const entries = scan(list, source.size);
108
+ const manifest = checkManifest(json(await readEntry(source, list[0]), "manifest.json"));
90
109
  const listed = /* @__PURE__ */ new Set();
91
110
  for (const file of manifest.files) {
92
111
  if (entries.get(file.path)?.size !== file.size || file.path === "manifest.json")
@@ -101,7 +120,7 @@ async function openProfile(bytes) {
101
120
  whereOf(manifest, name);
102
121
  }
103
122
  const { exportId, part } = manifest;
104
- return { manifest, entries, bytes, key: part ? `${exportId}#${part.index}` : exportId };
123
+ return { manifest, entries, source, key: part ? `${exportId}#${part.index}` : exportId };
105
124
  }
106
125
  const noLicks = { crontasks: [], tasks: [], webhooks: [] };
107
126
  async function licksIn(opened, data) {
@@ -109,14 +128,14 @@ async function licksIn(opened, data) {
109
128
  if (!name) return noLicks;
110
129
  const entry = opened.entries.get(name);
111
130
  if (!entry) throw new ImportError(`${name} is named in the manifest but not in the zip`);
112
- return checkLicks(json(data?.get(name) ?? await readEntry(opened.bytes, entry), name));
131
+ return checkLicks(json(data?.get(name) ?? await readEntry(opened.source, entry), name));
113
132
  }
114
133
  const UNITS = [
115
134
  [1e9, "GB"],
116
135
  [1e6, "MB"],
117
136
  [1e3, "KB"]
118
137
  ];
119
- async function previewOf(opened) {
138
+ async function previewOf(opened, state) {
120
139
  const { manifest } = opened;
121
140
  const licks = await licksIn(opened);
122
141
  const bytes = manifest.files.filter((file) => file.path.startsWith("files/")).reduce((sum, file) => sum + file.size, 0);
@@ -150,6 +169,7 @@ async function previewOf(opened) {
150
169
  flagged: manifest.flagged.length,
151
170
  providers: manifest.providers,
152
171
  mounts,
172
+ already: state.imports?.[opened.key]?.at ?? null,
153
173
  text: `SLICC 6 profile from ${host}, exported ${day(manifest.createdAt)}: ${parts.join(", ")}. Re-enter in Settings: ${manifest.providers.join(", ") || "nothing"}. Not imported: ${plural(mounts.length, "mount")}.`
154
174
  };
155
175
  }
@@ -157,13 +177,18 @@ async function sha256(bytes) {
157
177
  const digest = await crypto.subtle.digest("SHA-256", bytes);
158
178
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
159
179
  }
160
- async function verify(opened) {
180
+ async function verify(opened, writer, stage, show) {
161
181
  const data = /* @__PURE__ */ new Map();
162
- for (const file of opened.manifest.files) {
163
- const bytes = await readEntry(opened.bytes, opened.entries.get(file.path));
182
+ const { files } = opened.manifest;
183
+ for (const [at, file] of files.entries()) {
184
+ writer.check();
185
+ const bytes = await readEntry(opened.source, opened.entries.get(file.path));
164
186
  if (await sha256(bytes) !== file.sha256.toLowerCase())
165
187
  throw new ImportError(`${file.path} doesn't match its sha256`);
166
- if (whereOf(opened.manifest, file.path).kind === "data") data.set(file.path, bytes);
188
+ const where = whereOf(opened.manifest, file.path);
189
+ if (where.kind === "data") data.set(file.path, bytes);
190
+ else if (where.kind !== "skip" && stage) await writer.put(`${stage}/${file.path}`, bytes, true);
191
+ show("checking", at + 1, files.length);
167
192
  }
168
193
  return data;
169
194
  }
@@ -178,6 +203,71 @@ function transcriptIn(data, name) {
178
203
  throw new ImportError(`${name}: ${error.message}`);
179
204
  }
180
205
  }
206
+ function draft(fields, when, created) {
207
+ const last = fields.main?.messages.at(-1)?.timestamp;
208
+ const frozenAt = [...when, last, created].map((value) => Date.parse(value ?? "")).find((at) => !Number.isNaN(at));
209
+ const tail = (fields.doc?.conversations ?? []).map((item) => item.messages.at(-1)?.id ?? "").join(",");
210
+ return {
211
+ ...fields,
212
+ frozenAt,
213
+ tail,
214
+ cone: "",
215
+ fresh: true,
216
+ record: true,
217
+ suffix: "",
218
+ home: "",
219
+ roots: /* @__PURE__ */ new Set(),
220
+ files: /* @__PURE__ */ new Map()
221
+ };
222
+ }
223
+ function unitsOf(manifest, data) {
224
+ const cones = manifest.cones.map((cone) => {
225
+ const doc = transcriptIn(data, cone.transcript);
226
+ const all = doc?.conversations ?? [];
227
+ const main = all.find((item) => item.id === cone.conversation) ?? all.find((item) => item.kind === "cone");
228
+ const scoops = cone.scoops.map((scoop) => ({
229
+ name: scoop.name,
230
+ six: scoop.folder,
231
+ folder: "",
232
+ conversation: all.find((item) => item.id === scoop.conversation) ?? all.find((item) => item.kind === "scoop" && item.folder === scoop.folder)
233
+ }));
234
+ const title = doc?.session.title || cone.name;
235
+ return draft(
236
+ {
237
+ key: cone.key,
238
+ name: cone.name,
239
+ title,
240
+ six: cone.folder,
241
+ memory: cone.memory,
242
+ doc,
243
+ main,
244
+ scoops
245
+ },
246
+ [doc?.session.updatedAt],
247
+ manifest.createdAt
248
+ );
249
+ });
250
+ const frozen = manifest.frozen.map((item) => {
251
+ const doc = transcriptIn(data, item.transcript);
252
+ const [main, ...rest] = [...doc.conversations].sort(
253
+ (a, b) => Number(b.kind === "cone") - Number(a.kind === "cone")
254
+ );
255
+ const title = item.title || doc.session.title;
256
+ const scoops = rest.map((conversation) => ({
257
+ name: conversation.name,
258
+ six: null,
259
+ folder: "",
260
+ conversation
261
+ }));
262
+ const when = [item.frozenAt, doc.session.frozenAt, doc.session.updatedAt];
263
+ return draft(
264
+ { key: `frozen:${item.id}`, name: title, title, six: null, memory: null, doc, main, scoops },
265
+ when,
266
+ manifest.createdAt
267
+ );
268
+ });
269
+ return [...cones, ...frozen];
270
+ }
181
271
  function present(data, name) {
182
272
  if (name && !data.has(name))
183
273
  throw new ImportError(`${name} is named in the manifest but not in the zip`);
@@ -216,34 +306,325 @@ function related(manifest, data) {
216
306
  }
217
307
  for (const name of transcripts) attached(manifest, name, transcriptIn(data, name));
218
308
  }
219
- async function checked(opened, data) {
309
+ async function parse(opened, data) {
220
310
  for (const [name, bytes] of data)
221
311
  if (name.startsWith("memory/") || name === opened.manifest.gitconfig) textOf(bytes, name);
222
312
  related(opened.manifest, data);
223
- await licksIn(opened, data);
313
+ return { data, licks: await licksIn(opened, data), units: unitsOf(opened.manifest, data) };
224
314
  }
225
- async function bytesOf(env, source, context) {
226
- if ("bytes" in source) return source.bytes;
227
- const info = await env.fileInfo(source.path, context);
315
+ function prior(state, origin, key) {
316
+ const known = /* @__PURE__ */ new Set([
317
+ ...Object.keys(state.cones),
318
+ ...Object.values(state.frozen ?? {}).map((item) => item.cone)
319
+ ]);
320
+ return Object.values(state.imports ?? {}).filter((item) => item.origin === origin && known.has(item.cones[key])).sort((a, b) => b.at - a.at).map((item) => ({ cone: item.cones[key], tail: item.tails[key] }))[0];
321
+ }
322
+ async function assign(deps, opened, units, again, date, context) {
323
+ const { agents } = deps;
324
+ const origin = opened.manifest.producer.origin;
325
+ let fresh = 0;
326
+ for (const unit of units) {
327
+ const before = again ? void 0 : prior(agents.state(), origin, unit.key);
328
+ if (!before) fresh += 1;
329
+ if (!before) continue;
330
+ unit.cone = before.cone;
331
+ unit.fresh = false;
332
+ unit.record = before.tail !== unit.tail;
333
+ if (unit.record) unit.title = `${unit.title} (updated)`;
334
+ if (agents.state().cones[before.cone]) unit.suffix = `.v6-${date}`;
335
+ }
336
+ const first = fresh ? await agents.update((_tx, doc) => {
337
+ doc.next += fresh;
338
+ return doc.next - fresh;
339
+ }, context) : 0;
340
+ const taken = { ...agents.state().scoops };
341
+ let next = first;
342
+ for (const unit of units) {
343
+ if (unit.fresh) unit.cone = `cone-${next++}`;
344
+ unit.home = homeOf(unit.cone);
345
+ for (const scoop of unit.scoops) {
346
+ scoop.folder = freeFolder({ scoops: taken }, scoop.name);
347
+ taken[scoopId(scoop.folder)] = {};
348
+ }
349
+ }
350
+ }
351
+ const ZERO = {
352
+ input: 0,
353
+ output: 0,
354
+ cacheRead: 0,
355
+ cacheWrite: 0,
356
+ totalTokens: 0,
357
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
358
+ };
359
+ function piMessage(message, names, files) {
360
+ const timestamp = Date.parse(message.timestamp);
361
+ const texts = message.content.flatMap((part) => {
362
+ if (part.type === "text") return [{ type: "text", text: part.text }];
363
+ if (part.type === "tool-call") return [];
364
+ const file = files.get(part.attachmentId);
365
+ return [
366
+ {
367
+ type: "text",
368
+ text: file ? `[Attached file: ${file}]` : "[An attachment SLICC 6 did not export]"
369
+ }
370
+ ];
371
+ });
372
+ if (message.role === "user") return { role: "user", content: texts, timestamp };
373
+ const id = message.toolCallId;
374
+ if (message.role === "tool-result")
375
+ return {
376
+ role: "toolResult",
377
+ toolCallId: id,
378
+ toolName: names.get(id) ?? "tool",
379
+ content: texts,
380
+ isError: message.isError === true,
381
+ timestamp
382
+ };
383
+ const calls = message.content.flatMap((part) => {
384
+ if (part.type !== "tool-call") return [];
385
+ names.set(part.id, part.name);
386
+ const input = part.input;
387
+ const object = typeof input === "object" && input !== null && !Array.isArray(input);
388
+ return [
389
+ {
390
+ type: "toolCall",
391
+ id: part.id,
392
+ name: part.name,
393
+ arguments: object ? input : { input }
394
+ }
395
+ ];
396
+ });
397
+ const assistant = {
398
+ role: "assistant",
399
+ content: [...texts, ...calls],
400
+ api: message.model?.api ?? "slicc-6",
401
+ provider: message.model?.provider ?? "slicc-6",
402
+ model: message.model?.id ?? "unknown",
403
+ usage: message.usage ?? ZERO,
404
+ stopReason: calls.length ? "toolUse" : "stop",
405
+ timestamp
406
+ };
407
+ return assistant;
408
+ }
409
+ function entriesOf(conversation, files) {
410
+ const names = /* @__PURE__ */ new Map();
411
+ return (conversation?.messages ?? []).map((message) => ({
412
+ kind: V6_KIND,
413
+ data: { id: message.id },
414
+ model: [piMessage(message, names, files)]
415
+ }));
416
+ }
417
+ async function conversationIn(tx, conversation, unit, model) {
418
+ const record = await tx.createConversation({ ownership: { kind: "ownerless" } });
419
+ if (model) await configure(tx, record.id, { model });
420
+ for (const entry of entriesOf(conversation, unit.files)) await tx.appendEntry(record.id, entry);
421
+ return record.id;
422
+ }
423
+ function modelOf(conversation) {
424
+ const model = conversation?.messages.findLast((message) => message.model)?.model;
425
+ return model ? `${model.provider}/${model.id}` : "";
426
+ }
427
+ async function commit(deps, opened, units, at, context) {
428
+ const model = (await deps.agents.cone().agent(context)).model;
429
+ const origin = opened.manifest.producer.origin;
430
+ const last = TITLE_TURNS.at(-1);
431
+ return deps.agents.update(async (tx, doc) => {
432
+ const made = [];
433
+ for (const unit of units.filter((item) => item.record)) {
434
+ const frozenId = `frozen-${doc.frozenNext ?? 1}`;
435
+ doc.frozenNext = (doc.frozenNext ?? 1) + 1;
436
+ const scoops = [];
437
+ for (const scoop of unit.scoops) {
438
+ const id = scoopId(scoop.folder);
439
+ if (doc.scoops[id])
440
+ throw new ImportError(`the scoop folder ${scoop.folder} was taken during the import`);
441
+ const conversation = await conversationIn(tx, scoop.conversation, unit, model);
442
+ doc.scoops[id] = {
443
+ name: scoop.name,
444
+ folder: scoop.folder,
445
+ cone: unit.cone,
446
+ conversation,
447
+ anchor: 0,
448
+ role: null,
449
+ kind: "async",
450
+ parent: unit.cone,
451
+ depth: 1,
452
+ roots: null,
453
+ created: null,
454
+ dropped: null,
455
+ gone: true,
456
+ frozen: frozenId,
457
+ title: scoop.name,
458
+ titled: last
459
+ };
460
+ scoops.push(id);
461
+ }
462
+ doc.frozen ??= {};
463
+ doc.frozen[frozenId] = {
464
+ cone: unit.cone,
465
+ name: unit.name,
466
+ conversation: await conversationIn(tx, unit.main, unit, model),
467
+ title: unit.title,
468
+ model: modelOf(unit.main),
469
+ messages: (unit.main?.messages ?? []).filter((message) => message.role !== "tool-result").length,
470
+ frozenAt: unit.frozenAt,
471
+ scoops,
472
+ v6: { origin, key: unit.key, ...unit.suffix ? { suffix: unit.suffix } : {} }
473
+ };
474
+ made.push(frozenId);
475
+ }
476
+ doc.imports ??= {};
477
+ doc.imports[opened.key] = {
478
+ at,
479
+ origin,
480
+ cones: Object.fromEntries(units.map((unit) => [unit.key, unit.cone])),
481
+ tails: Object.fromEntries(units.map((unit) => [unit.key, unit.tail]))
482
+ };
483
+ return made;
484
+ }, context);
485
+ }
486
+ async function storageRoom() {
487
+ const estimate = await globalThis.navigator?.storage?.estimate?.().catch(() => void 0);
488
+ return estimate?.quota === void 0 ? void 0 : estimate.quota - (estimate.usage ?? 0);
489
+ }
490
+ async function importOpened(deps, opened, options, context) {
491
+ const now = (deps.now ?? Date.now)();
492
+ const date = day(now);
493
+ const writer = new Writer(deps.env, context, options.signal);
494
+ const stage = `${IMPORTS_DIR}/${opened.key.replace("#", "-part")}`;
495
+ const total = opened.manifest.files.reduce((sum, file) => sum + file.size, 0);
496
+ const room = await (deps.room ?? storageRoom)();
497
+ if (room !== void 0 && room < 2 * total)
498
+ throw new ImportError("there is not enough free storage for this import");
499
+ try {
500
+ await writer.sh(
501
+ `mkdir -p ${IMPORTS_DIR} && chmod 700 ${IMPORTS_DIR} && rm -rf ${quote(stage)} && mkdir -p ${quote(stage)}`
502
+ );
503
+ const data = await verify(opened, writer, stage, options.show);
504
+ const parsed = await parse(opened, data);
505
+ await assign(deps, opened, parsed.units, options.again, date, context);
506
+ const run = {
507
+ deps,
508
+ opened,
509
+ parsed,
510
+ writer,
511
+ stage,
512
+ date,
513
+ show: options.show,
514
+ fresh: /* @__PURE__ */ new Map(),
515
+ ready: /* @__PURE__ */ new Set()
516
+ };
517
+ let made;
518
+ try {
519
+ await place(run);
520
+ made = await commit(deps, opened, parsed.units, now, context);
521
+ } catch (error) {
522
+ await writer.rollback();
523
+ throw error;
524
+ }
525
+ const cones = [...new Set(parsed.units.map((unit) => unit.cone))];
526
+ const providers = opened.manifest.providers;
527
+ return {
528
+ done: true,
529
+ preview: null,
530
+ cones,
531
+ report: null,
532
+ text: `Imported ${plural(made.length, "frozen cone")} from ${opened.manifest.producer.origin}${made.length ? `: ${made.join(", ")}` : ""}.
533
+ Re-enter in Settings: ${providers.join(", ") || "nothing"}.
534
+ `,
535
+ error: null
536
+ };
537
+ } finally {
538
+ await deps.env.remove(stage, { recursive: true, force: true }, BACKGROUND_CONTEXT);
539
+ }
540
+ }
541
+ async function sourceOf(deps, source, context) {
542
+ if ("bytes" in source) return bytesSource(source.bytes);
543
+ const { env } = deps;
544
+ const real = await env.canonicalPath(source.path, context);
545
+ const info = real.ok ? await env.fileInfo(real.value, context) : real;
228
546
  if (!info.ok) throw new ImportError(`can't read ${source.path}: ${info.error.message}`);
229
547
  if (info.value.size > LIMITS.archive) throw new ImportError("the zip is over 2 GiB");
230
- const read = await env.readBinaryFile(source.path, context);
548
+ const file = await opfsFile(info.value.path, deps.opfs);
549
+ if (file?.size === info.value.size) return blobSource(file);
550
+ if (info.value.size > LIMITS.file)
551
+ throw new ImportError(`${source.path} isn't on seven's own disk; copy the zip to /tmp first`);
552
+ const read = await env.readBinaryFile(info.value.path, context);
231
553
  if (!read.ok) throw new ImportError(`can't read ${source.path}: ${read.error.message}`);
232
- return read.value;
554
+ return bytesSource(read.value);
233
555
  }
234
- async function checkProfile(env, source, context) {
235
- const opened = await openProfile(await bytesOf(env, source, context));
236
- const preview = await previewOf(opened);
237
- await checked(opened, await verify(opened));
238
- return preview;
556
+ const IDLE = { phase: "idle", done: 0, total: 0, text: "" };
557
+ function createImporter(deps) {
558
+ const progress = replicatedState(IDLE);
559
+ let running;
560
+ const result = (fields) => ({
561
+ done: false,
562
+ preview: null,
563
+ cones: [],
564
+ report: null,
565
+ text: "",
566
+ error: null,
567
+ ...fields
568
+ });
569
+ return {
570
+ progress,
571
+ cancel: () => running?.abort(),
572
+ async run(source, options, context) {
573
+ if (running) return result({ error: "an import is already running" });
574
+ const controller = new AbortController();
575
+ running = controller;
576
+ const show = (phase, done, total) => progress.replace(BACKGROUND_CONTEXT, {
577
+ phase,
578
+ done,
579
+ total,
580
+ text: phase === "checking" ? `Checking ${done}/${total}` : `Importing ${done}/${total}`
581
+ });
582
+ try {
583
+ const opened = await openProfile(await sourceOf(deps, source, context));
584
+ const preview = await previewOf(opened, deps.agents.state());
585
+ if (options.preview) {
586
+ const writer = new Writer(deps.env, context, controller.signal);
587
+ await parse(opened, await verify(opened, writer, null, show));
588
+ progress.replace(BACKGROUND_CONTEXT, IDLE);
589
+ return result({ preview, text: `${preview.text}
590
+ ` });
591
+ }
592
+ if (preview.already !== null && !options.again)
593
+ return result({
594
+ preview,
595
+ text: `Already imported on ${day(preview.already)}; freezer import --again makes fresh copies.
596
+ `
597
+ });
598
+ const done = await importOpened(
599
+ deps,
600
+ opened,
601
+ { again: options.again === true, signal: controller.signal, show },
602
+ context
603
+ );
604
+ progress.replace(BACKGROUND_CONTEXT, { ...progress.value, phase: "done", text: done.text });
605
+ return { ...done, preview };
606
+ } catch (error) {
607
+ const message = error instanceof ImportError ? error.message : `the import failed: ${error.message}`;
608
+ progress.replace(BACKGROUND_CONTEXT, { phase: "failed", done: 0, total: 0, text: message });
609
+ return result({ error: message });
610
+ } finally {
611
+ running = void 0;
612
+ }
613
+ }
614
+ };
239
615
  }
240
616
  export {
617
+ IDLE,
618
+ IMPORTS_DIR,
241
619
  LIMITS,
242
- checkProfile,
620
+ V6_KIND,
621
+ createImporter,
243
622
  day,
623
+ entriesOf,
244
624
  openProfile,
245
625
  previewOf,
246
626
  textOf,
627
+ unitsOf,
247
628
  whereOf
248
629
  };
249
630
  //# sourceMappingURL=v6.js.map