samagotchi 0.53.0 → 0.55.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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -1
  3. data/README.md +1 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +94 -54
  6. data/docs/configuration.md +30 -2
  7. data/docs/context.md +104 -9
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/bundles.md +73 -0
  10. data/docs/internals/context-telemetry.md +27 -6
  11. data/docs/internals/engine.md +55 -24
  12. data/docs/internals/tool-guardrails.md +8 -6
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +3 -3
  15. data/docs/testing.md +23 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -6
  17. data/lib/samagotchi/bundle_command.rb +31 -36
  18. data/lib/samagotchi/bundle_needs.rb +4 -5
  19. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/child_move.rb +6 -4
  24. data/lib/samagotchi/config.rb +2 -1
  25. data/lib/samagotchi/context_command.rb +1 -1
  26. data/lib/samagotchi/context_providers.rb +11 -14
  27. data/lib/samagotchi/context_status.rb +10 -18
  28. data/lib/samagotchi/engine.rb +117 -134
  29. data/lib/samagotchi/extension_load.rb +102 -0
  30. data/lib/samagotchi/file_ref.rb +59 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/kernel_loop.rb +5 -3
  33. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  34. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  35. data/lib/samagotchi/memory_bundle/builder.rb +170 -178
  36. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  37. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  38. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  39. data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
  40. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  41. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  42. data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
  43. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  44. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  45. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  46. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  47. data/lib/samagotchi/plugin/loader.rb +8 -8
  48. data/lib/samagotchi/self_report.rb +2 -2
  49. data/lib/samagotchi/send_command.rb +1 -1
  50. data/lib/samagotchi/session_continue.rb +275 -0
  51. data/lib/samagotchi/session_manager.rb +9 -253
  52. data/lib/samagotchi/session_metrics.rb +3 -1
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  54. data/lib/samagotchi/terminal_ui.rb +12 -2
  55. data/lib/samagotchi/tool_activity.rb +84 -14
  56. data/lib/samagotchi/tool_row_fields.rb +46 -0
  57. data/lib/samagotchi/tool_runner.rb +7 -11
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +26 -3
  60. data/lib/samagotchi/web/capabilities.rb +13 -9
  61. data/lib/samagotchi/web/editor.rb +38 -0
  62. data/lib/samagotchi/web/message_parts.rb +5 -10
  63. data/lib/samagotchi/web/public/activity.js +35 -18
  64. data/lib/samagotchi/web/public/app.js +240 -1136
  65. data/lib/samagotchi/web/public/commands.js +236 -0
  66. data/lib/samagotchi/web/public/composer.js +101 -0
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/diff_view.js +30 -4
  69. data/lib/samagotchi/web/public/index.html +9 -2
  70. data/lib/samagotchi/web/public/info_bar.js +78 -0
  71. data/lib/samagotchi/web/public/lists_view.js +725 -0
  72. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  73. data/lib/samagotchi/web/public/question_card.js +9 -1
  74. data/lib/samagotchi/web/public/question_cards.js +7 -4
  75. data/lib/samagotchi/web/public/refs.js +134 -0
  76. data/lib/samagotchi/web/public/restore.js +81 -0
  77. data/lib/samagotchi/web/public/session_state.js +52 -0
  78. data/lib/samagotchi/web/public/stream_handlers.js +324 -0
  79. data/lib/samagotchi/web/public/thinking_chip.js +6 -7
  80. data/lib/samagotchi/web/public/timing.js +8 -2
  81. data/lib/samagotchi/web/public/turn_events.js +17 -258
  82. data/lib/samagotchi/web/public/turn_html.js +15 -7
  83. data/lib/samagotchi/web/server.rb +4 -4
  84. metadata +17 -2
  85. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -1,6 +1,8 @@
1
1
  // Pure helpers for the Bridge's turn events (no DOM), so app.js's handlers
2
2
  // stay thin and the logic runs under `node --test`.
3
3
 
4
+ import { ACTIVITY_FIELDS, EVENT_FIELDS, pickFields } from "./activity.js";
5
+
4
6
  // The answer text of a :turn_completed event. `turn_summary.output` is the
5
7
  // contract; `result` is the KernelLoop result, which reaches the wire as its
6
8
  // string form (older servers, other clients' fixtures).
@@ -189,6 +191,11 @@ export function llmContextHover({ groups = [], freed_tokens: freed, tail_tokens:
189
191
  return lines.join("\n");
190
192
  }
191
193
 
194
+ // A ✂ row's notice data (turnDom.notice, placeTurnNotice).
195
+ export function llmContextNotice(data) {
196
+ return { line: llmContextLine(data), cls: "llm-context", title: llmContextHover(data) };
197
+ }
198
+
192
199
  // The one muted line for a turn that ended with no answer (turn_completed's
193
200
  // turn_summary.empty_answer, or a reloaded turn's marker): the TUI words
194
201
  // it the same (TurnNote.empty_answer_line; spec/shared/labels_matrix.json).
@@ -229,257 +236,6 @@ export function initWaitLine({ tasks } = {}) {
229
236
  return `waiting for ${labels.length ? labels.join(" · ") : "plugins"}…`;
230
237
  }
231
238
 
232
- // Whether a composer line in an open session is one of its commands
233
- // (+commands+: the session GET's listing), as the terminals decide
234
- // (Commands::Registry#lookup): a "!" line with something after the "!", or
235
- // a listed name alone or followed by a space; a terminal's own (/stats,
236
- // /exit) too, which the page answers itself (webLocalReply). An unknown
237
- // "/word" (a path, a typo) goes to the model as a prompt, as in a terminal.
238
- export function sessionCommandLine(text, commands) {
239
- const line = String(text || "").trim();
240
- if (/^!\s*\S/.test(line)) return true;
241
- if (!line.startsWith("/")) return false;
242
- if (webLocalReply(line)) return true;
243
- return (Array.isArray(commands) ? commands : [])
244
- .some((c) => c && typeof c.name === "string" && (line === c.name || line.startsWith(`${c.name} `)));
245
- }
246
-
247
- // /cut TEXT and /queue TEXT: the UIs' own words for a delivery (listed
248
- // for Tab and /help, never run as commands; TerminalUI::Formatting's
249
- // STEER_CHOICE: the word, then any whitespace and the text). During a
250
- // turn /cut TEXT is the text sent as a cut (POST /turn, delivery "cut"),
251
- // as chi send --cut does; /queue isn't in the web yet (the page says so,
252
- // as the attached TUI does). With no running turn both are the text as a
253
- // normal turn. The bare word gets the usage line.
254
- // @return {{send: string, delivery?: string} | {reply: string} | null}
255
- // null: not a delivery line
256
- export const STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)";
257
- export const QUEUE_UNAVAILABLE_WEB = "(/queue isn't available in the web yet; a plain message goes in at the running turn's next step, and /cut TEXT cuts in)";
258
- const STEER_CHOICE = /^\/(cut|queue)(?:\s+([\s\S]*))?$/;
259
-
260
- export function steerChoiceRoute(text, { running = false } = {}) {
261
- const match = STEER_CHOICE.exec(String(text || "").trim());
262
- if (!match) return null;
263
- const message = (match[2] || "").trim();
264
- if (!message) return { reply: STEER_CHOICE_USAGE };
265
- if (!running) return { send: message };
266
- if (match[1] === "queue") return { reply: QUEUE_UNAVAILABLE_WEB };
267
- return { send: message, delivery: "cut" };
268
- }
269
-
270
- // A line that is one word: a slash and a name, no spaces, no second slash
271
- // (Commands::Registry#unknown_command_word?). `/modle` is one; `/foo bar`
272
- // and `/usr/bin/env` are not (they are prompts).
273
- const UNKNOWN_COMMAND_WORD = /^\/[A-Za-z][A-Za-z0-9_-]*$/;
274
-
275
- // The hint for a command word no command answers (a typo like `/modle`),
276
- // or null when the line is not one: `Unknown command /modle. Did you mean
277
- // /model? /help lists the commands.` The "Did you mean" part needs a close
278
- // name among the session's commands and the page's own (webLocalReply).
279
- export function unknownCommandHint(text, commands) {
280
- const line = String(text || "").trim();
281
- if (!UNKNOWN_COMMAND_WORD.test(line)) return null;
282
- if (sessionCommandLine(line, commands)) return null;
283
- const names = (Array.isArray(commands) ? commands : [])
284
- .map((c) => (c && typeof c.name === "string" ? c.name : null))
285
- .filter(Boolean)
286
- .concat(Object.keys(WEB_LOCAL_REPLIES));
287
- const close = closestName(line, names);
288
- return `Unknown command ${line}. ${close ? `Did you mean ${close}? ` : ""}/help lists the commands.`;
289
- }
290
-
291
- // The closest name within an optimal string alignment distance of 2, or
292
- // null. Ties go to the first in the list.
293
- function closestName(line, names) {
294
- let best = null;
295
- let bestDistance = 3;
296
- for (const name of names) {
297
- const distance = osaDistance(line, name);
298
- if (distance < bestDistance) {
299
- best = name;
300
- bestDistance = distance;
301
- }
302
- }
303
- return best;
304
- }
305
-
306
- // Optimal string alignment distance: Levenshtein, with an adjacent
307
- // transposition counting as 1 (so `/modle` is 1 from `/model`, as Ruby's
308
- // DidYouMean reads it).
309
- function osaDistance(a, b) {
310
- const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
311
- for (let j = 0; j <= b.length; j += 1) rows[0][j] = j;
312
- for (let i = 1; i <= a.length; i += 1) {
313
- for (let j = 1; j <= b.length; j += 1) {
314
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
315
- rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
316
- if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
317
- rows[i][j] = Math.min(rows[i][j], rows[i - 2][j - 2] + 1);
318
- }
319
- }
320
- }
321
- return rows[a.length][b.length];
322
- }
323
-
324
- // The terminal's own commands (a button in the web, or none yet): the
325
- // reply the page shows itself (they never go to the worker, which doesn't
326
- // know them), or null.
327
- const WEB_LOCAL_REPLIES = {
328
- "/archive": "/archive: use the archive button in the session bar",
329
- "/detach": "/detach: a terminal's command; close the tab to leave, the worker keeps running",
330
- "/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
331
- "/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
332
- "/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now",
333
- "/stats": "/stats: not in the web yet; each turn shows its time, the session bar the context use and speed (hover ctx for tokens and cost), and /stats in a terminal (chi --attach) has the rest",
334
- };
335
-
336
- export function webLocalReply(text) {
337
- const name = String(text || "").trim().split(/\s+/)[0].toLowerCase();
338
- return WEB_LOCAL_REPLIES[name] || null;
339
- }
340
-
341
- // The start page's first message when the page answers it itself
342
- // (webLocalReply: /stats, /exit …): shown there, with no session made just
343
- // to hold the reply. With images it is a message, as in a session.
344
- export function startPageReply(text, { images = 0 } = {}) {
345
- return images ? null : webLocalReply(text);
346
- }
347
-
348
- // The start page's reply to a first message that needs no session at all
349
- // (2.29): its own command's reply (startPageReply), or the hint for a
350
- // command word no command answers (a typo like /modle) — the hint the page
351
- // shows in a session too, as a command bubble. Decided before the session
352
- // is created, so a first /stats or /modle leaves none behind. A null here
353
- // means the message goes to a session (a prompt, or anything with images).
354
- export function startPageReplyOrHint(text, { commands = [], images = 0 } = {}) {
355
- if (images) return null;
356
- return startPageReply(text) || unknownCommandHint(text, commands);
357
- }
358
-
359
- // The continue card's answers, as the terminal's continue prompt reads them.
360
- export function continueLine(choice, reason = "") {
361
- const why = String(reason || "").trim();
362
- if (choice === "no" && why) return `/continue no, ${why}`;
363
- return `/continue ${choice}`;
364
- }
365
-
366
- // How to show a command_ran: `label` names another client (null for this
367
- // tab's own), `resync` says the conversation changed (!rollback, !cmd, a
368
- // continue answered no) and must be read again. An anytime command's
369
- // (`anytime`, D8) bubble is drawn at its command_queued, before the cards
370
- // it shows; its command_ran fills that bubble in (`commandId`). So is a
371
- // command queued for after the running turn (`queued`): its command_queued
372
- // (waits: "turn_end") draws it `waiting`, saying so, until it ran or was
373
- // dropped (styled as failed).
374
- export const QUEUED_TEXT = "queued: runs after this turn";
375
-
376
- export function commandView(event, myId) {
377
- const own = isOwn(event.client_id, myId);
378
- const waiting = event.waits === "turn_end";
379
- return {
380
- label: own ? null : clientLabel(event.client_id),
381
- line: event.line || "",
382
- text: event.output || (waiting ? QUEUED_TEXT : ""),
383
- busy: event.status === "busy",
384
- failed: event.status === "error" || event.status === "dropped",
385
- resync: (event.changed || []).includes("messages"),
386
- modelName: event.model_name || null,
387
- anytime: event.anytime === true,
388
- queued: waiting || event.queued === true,
389
- waiting,
390
- commandId: event.command_id || null,
391
- // A card's action (Nudge…): no bubble unless it says something back.
392
- hidden: event.card === true && !event.output && event.status !== "error",
393
- };
394
- }
395
-
396
- // Whether a command_ran that re-read the conversation (`resync`) needs its
397
- // own bubble: the redrawn history ends with a command bubble for its line
398
- // (a `!cmd` appends its "!(<command>)\n<output>" message, which renders as
399
- // one), so a second bubble would show the output twice. +historyBubbles+:
400
- // the `.bubble.command` elements in document order; +line+: the event's
401
- // line. Only the last one counts: it is the redrawn history's end.
402
- export function historyHasCommandBubble(historyBubbles, line) {
403
- const last = historyBubbles[historyBubbles.length - 1];
404
- return !!last && last.querySelector(":scope > .command-line")?.textContent === line;
405
- }
406
-
407
- // What a prompt_restored (the worker rolled a failed turn back and handed its
408
- // prompt back) does in this tab: `refill` is the text for an empty composer,
409
- // only for a prompt this page sent (`sentIds`, its acks' enqueued_ids), so a
410
- // replay after a reload never refills stale text; `own`/`label` mark the
411
- // failed bubble shown again after the resync.
412
- export function restoreAction(event, { myId, sentIds, composerEmpty }) {
413
- const origin = event?.origin || {};
414
- const own = isOwn(origin.client_id, myId);
415
- const sent = own && !!origin.enqueued_id && sentIds.has(origin.enqueued_id);
416
- // The images go back as chips with the text.
417
- return withImages({
418
- refill: sent && composerEmpty ? event.prompt : null,
419
- own,
420
- label: own ? null : clientLabel(origin.client_id),
421
- }, sent && composerEmpty ? event.images : null);
422
- }
423
-
424
- // A turn that fails fast can have its prompt_restored come over the stream
425
- // before the /turn ack names its enqueued_id: `early` (a Map) keeps such a
426
- // restore of this tab's own prompt by that id, and the ack takes it back
427
- // (restoreOnAck), so the prompt goes into the composer the ack would clear.
428
- export function keepEarlyRestore(event, early, { myId, sentIds }) {
429
- const origin = event?.origin || {};
430
- const id = origin.enqueued_id;
431
- if (!isOwn(origin.client_id, myId) || !id || sentIds.has(id)) return false;
432
- early.set(id, event);
433
- return true;
434
- }
435
-
436
- // The ack's enqueued_id (already in sentIds) was restored early: what the
437
- // composer gets back (as restoreAction for an empty composer), else null.
438
- export function restoreOnAck(enqueuedId, early, { myId, sentIds }) {
439
- const event = enqueuedId && early.get(enqueuedId);
440
- if (!event) return null;
441
- early.delete(enqueuedId);
442
- return restoreAction(event, { myId, sentIds, composerEmpty: true });
443
- }
444
-
445
- // The /turn this kept entry was for failed: no ack comes, so nothing would
446
- // take it back. The whole map goes: this tab sends one turn at a time, so
447
- // every entry waits for an ack that is now never coming (an older one is
448
- // left over from a send that failed the same way).
449
- export function dropEarlyRestores(early) {
450
- const count = early.size;
451
- early.clear();
452
- return count;
453
- }
454
-
455
- // The composer's text with a restored prompt put back into it: the restored
456
- // text alone when nothing is typed, else the typed text with the restored
457
- // prompt under it (the user typed between the restore and the ack; their
458
- // text must not be replaced).
459
- export function restoreInto(refill, current) {
460
- const restored = String(refill ?? "");
461
- const typed = String(current ?? "");
462
- if (!restored) return typed;
463
- if (!typed.trim()) return restored;
464
- return `${typed.replace(/\n+$/, "")}\n${restored}`;
465
- }
466
-
467
- // What the composer holds once a /turn ack lands (4.13): the composer still
468
- // carries the text that was just sent, so that text goes (it is the history's
469
- // now) — but text typed since the send stays, and a prompt restored before
470
- // this ack (restoreOnAck's refill) is put back without replacing it. The
471
- // sent text is trimmed (turnText), so the composer's is too before they are
472
- // compared: a trailing newline or space must not keep the message; and an
473
- // image-only send (placeholders sent) leaves nothing worth keeping.
474
- // @param sent the text the turn was sent with
475
- // @param current the composer's text as the ack lands
476
- // @param refill the prompt an early restore handed back, if any
477
- export function composerAfterAck({ sent = "", current = "", refill = "" } = {}) {
478
- const typed = String(current ?? "").trim();
479
- const kept = !typed || typed === String(sent ?? "").trim() ? "" : current;
480
- return restoreInto(refill, kept);
481
- }
482
-
483
239
  // What an event does to the prompt bubbles, as a list of ops for app.js:
484
240
  // add — a bubble for a prompt this tab hasn't shown (state queued /
485
241
  // started / steered, labelled by its sender)
@@ -580,10 +336,11 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
580
336
  closeText();
581
337
  const call = { iteration: part.iteration, call_index: part.call_index, tool: part.tool };
582
338
  if (part.label) call.label = part.label;
583
- const title = part.title ? { title: part.title } : {};
584
- const alias = part.called_as ? { called_as: part.called_as } : {};
585
- const view = part.view ? { view: part.view } : {};
586
- events.push({ type: "tool_call_started", ...call, params: part.params, ...title, ...alias, ...view });
339
+ // Its row fields, each in its place (activity.js ACTIVITY_FIELDS,
340
+ // EVENT_FIELDS), as the live events carry them.
341
+ const title = pickFields(part, ACTIVITY_FIELDS);
342
+ const fields = pickFields(part, EVENT_FIELDS);
343
+ events.push({ type: "tool_call_started", ...call, params: part.params, ...title, ...fields });
587
344
  if (part.status !== "running") {
588
345
  // The activity as the live event had it; action and duration_ms
589
346
  // only in snapshots from a worker that keeps them (the TUI's
@@ -591,11 +348,13 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
591
348
  const action = part.action ? { action: part.action } : {};
592
349
  const completed = {
593
350
  type: "tool_call_completed", ...call, output: part.output, output_truncated: !!part.output_truncated,
594
- activity: { ...action, tool: part.tool, status: part.status, params: part.params, ...title, ...alias },
351
+ activity: { ...action, tool: part.tool, status: part.status, params: part.params, ...title },
595
352
  };
353
+ // A grep that found nothing: its row stays muted "no match".
354
+ if (part.no_match) completed.activity.no_match = true;
596
355
  if (part.duration_ms != null) completed.duration_ms = part.duration_ms;
597
356
  if (part.diff) completed.diff = part.diff;
598
- if (part.view) completed.view = part.view;
357
+ Object.assign(completed, fields);
599
358
  events.push(withImages(completed, part.images));
600
359
  }
601
360
  break;
@@ -637,6 +396,6 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
637
396
 
638
397
  // +object+ with `images` when there are any (the shapes stay as they were
639
398
  // for a turn without).
640
- function withImages(object, images) {
399
+ export function withImages(object, images) {
641
400
  return images?.length ? { ...object, images } : object;
642
401
  }
@@ -7,21 +7,26 @@ import { commandBlockHtml, rowHover } from "./command_view.js";
7
7
  import { copySourceAttr } from "./copy.js";
8
8
  import { activityDiffHtml } from "./diff_view.js";
9
9
  import { escapeHtml, userBodyHtml } from "./format.js";
10
+ import { refAttrs } from "./refs.js";
10
11
  import { formatDuration } from "./timing.js";
11
12
  import { emptyAnswerLine } from "./turn_events.js";
12
13
 
13
14
  // A row's status (live, or a saved tool record's on reload): running,
14
15
  // error, stopped (a task_wait the user's Stop ended), blocked (a guardrail
15
- // denied it), else ok ("done").
16
+ // denied it), else ok ("done"; a grep that found nothing is ok too, with
17
+ // a "no match" word, see statusParts).
16
18
  const NAMED_STATUSES = ["running", "error", "stopped", "blocked"];
17
19
 
18
20
  // A row's status as its parts: the mark's class (CSS draws the glyph), its
19
21
  // label (aria-label and title) and the word an exception shows after the
20
- // title (empty for done and running).
21
- export function statusParts(status) {
22
+ // title (empty for done and running). A done grep that found nothing
23
+ // (+noMatch+, the activity's no_match) keeps the done mark and shows a
24
+ // muted "no match" word (its own class, wordCls).
25
+ export function statusParts(status, { noMatch = false } = {}) {
22
26
  const named = NAMED_STATUSES.includes(status);
23
27
  const cls = named ? status : "ok";
24
28
  const label = named ? status : "done";
29
+ if (cls === "ok" && noMatch) return { cls, label, word: "no match", wordCls: "no-match" };
25
30
  return { cls, label, word: cls === "ok" || cls === "running" ? "" : label };
26
31
  }
27
32
 
@@ -29,8 +34,8 @@ function statusMarkHtml({ cls, label }) {
29
34
  return `<span class="activity-status ${cls}" role="img" aria-label="${label}" title="${label}"></span>`;
30
35
  }
31
36
 
32
- function statusWordHtml({ cls, word }) {
33
- return word ? `<span class="activity-state ${cls}">${word}</span>` : "";
37
+ function statusWordHtml({ cls, word, wordCls }) {
38
+ return word ? `<span class="activity-state ${wordCls || cls}">${word}</span>` : "";
34
39
  }
35
40
 
36
41
  // A reloaded row's ✂ mark: what an LLM context edit saved on its output
@@ -77,7 +82,10 @@ export function toolRowHtml(row, { thumbs = () => "" } = {}) {
77
82
  export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
78
83
  const shown = row.title || row.params;
79
84
  const hover = rowHover(row) ? ` title="${escapeHtml(rowHover(row))}"` : "";
80
- const params = shown ? `<span class="activity-params"${hover}>${escapeHtml(shown)}</span>` : "";
85
+ // A row with a ref (a file tool's file) carries it on its title, which
86
+ // opens it, and its diff lines open it at their line (refs.js).
87
+ const ref = shown ? refAttrs(row.ref) : "";
88
+ const params = shown ? `<span class="activity-params${ref ? " ref" : ""}"${hover}${ref}>${escapeHtml(shown)}</span>` : "";
81
89
  // The name the model called the tool by when it was an alias's, dim.
82
90
  const alias = row.called_as ? `<span class="activity-alias">(called as ${escapeHtml(row.called_as)})</span>` : "";
83
91
  const duration = formatDuration(row.duration_ms);
@@ -85,7 +93,7 @@ export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
85
93
  const output = full
86
94
  ? `<div class="activity-output" title="${escapeHtml(full)}">${escapeHtml(full.length > 300 ? `${full.slice(0, 300)}…` : full)}</div>`
87
95
  : "";
88
- const status = statusParts(row.status);
96
+ const status = statusParts(row.status, { noMatch: !!row.no_match });
89
97
  return `${statusMarkHtml(status)}<span class="activity-tool">${escapeHtml(toolName(row))}</span>${params}${alias}${statusWordHtml(status)}` +
90
98
  `${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
91
99
  `${commandBlockHtml(row.view, row.title)}${llmEditHtml(row)}${output}` +
@@ -66,7 +66,7 @@ module Samagotchi
66
66
  # @return [Integer] the exit status
67
67
  def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false,
68
68
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
69
- new_token: false)
69
+ editor: Config::BY_KEY["web.editor"].default, new_token: false)
70
70
  rotate_token if new_token
71
71
  setting = host_setting(host)
72
72
  lan = Lan.wanted?(setting) ? Lan.choose(setting) : nil
@@ -95,7 +95,7 @@ module Samagotchi
95
95
  return 0
96
96
  end
97
97
  if start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, view: view,
98
- annotate_presets: annotate_presets, lan: lan)
98
+ annotate_presets: annotate_presets, editor: editor, lan: lan)
99
99
  0
100
100
  else
101
101
  1
@@ -119,7 +119,7 @@ module Samagotchi
119
119
  # @return [Boolean] false when the port was taken (said so on stderr)
120
120
  def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
121
121
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
122
- token_path: Token.path)
122
+ editor: Config::BY_KEY["web.editor"].default, token_path: Token.path)
123
123
  port = resolve_port(port)
124
124
  host = resolve_host(host)
125
125
  url ||= "http://#{url_host(host)}:#{port}/"
@@ -130,7 +130,7 @@ module Samagotchi
130
130
  lan_option = { ip: lan.ip, token: Token::Source.new(token_path) }
131
131
  end
132
132
  app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, view: view,
133
- annotate_presets: annotate_presets, hub: hub, lan: lan_option)
133
+ annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option)
134
134
  Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
135
135
  Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
136
136
  # Subscribed before the first scan, which makes the first check.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.53.0
4
+ version: 0.55.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -131,6 +131,7 @@ files:
131
131
  - docs/guardrails.md
132
132
  - docs/hooks.md
133
133
  - docs/internals/background-tasks.md
134
+ - docs/internals/bundles.md
134
135
  - docs/internals/context-telemetry.md
135
136
  - docs/internals/engine.md
136
137
  - docs/internals/gemma4-contract.md
@@ -258,6 +259,8 @@ files:
258
259
  - lib/samagotchi/engine.rb
259
260
  - lib/samagotchi/engine_peers.rb
260
261
  - lib/samagotchi/events.rb
262
+ - lib/samagotchi/extension_load.rb
263
+ - lib/samagotchi/file_ref.rb
261
264
  - lib/samagotchi/gem_update.rb
262
265
  - lib/samagotchi/generation_phase.rb
263
266
  - lib/samagotchi/git_head.rb
@@ -332,16 +335,19 @@ files:
332
335
  - lib/samagotchi/log_path.rb
333
336
  - lib/samagotchi/log_subscriber.rb
334
337
  - lib/samagotchi/memory_bundle.rb
338
+ - lib/samagotchi/memory_bundle/asset_installer.rb
335
339
  - lib/samagotchi/memory_bundle/builder.rb
336
340
  - lib/samagotchi/memory_bundle/bundle_hook.rb
337
341
  - lib/samagotchi/memory_bundle/index_size.rb
338
342
  - lib/samagotchi/memory_bundle/index_sync.rb
339
343
  - lib/samagotchi/memory_bundle/index_updater.rb
344
+ - lib/samagotchi/memory_bundle/installed_bundle.rb
340
345
  - lib/samagotchi/memory_bundle/installer.rb
341
346
  - lib/samagotchi/memory_bundle/listing.rb
342
347
  - lib/samagotchi/memory_bundle/manifest.rb
343
348
  - lib/samagotchi/memory_bundle/merger.rb
344
349
  - lib/samagotchi/memory_bundle/placeholder.rb
350
+ - lib/samagotchi/memory_bundle/plugin_ref.rb
345
351
  - lib/samagotchi/memory_bundle/profile.rb
346
352
  - lib/samagotchi/memory_bundle/provenance.rb
347
353
  - lib/samagotchi/memory_bundle/shipped_update.rb
@@ -405,6 +411,7 @@ files:
405
411
  - lib/samagotchi/session_archive_command.rb
406
412
  - lib/samagotchi/session_chain.rb
407
413
  - lib/samagotchi/session_commands.rb
414
+ - lib/samagotchi/session_continue.rb
408
415
  - lib/samagotchi/session_delete_command.rb
409
416
  - lib/samagotchi/session_inbox.rb
410
417
  - lib/samagotchi/session_manager.rb
@@ -451,6 +458,7 @@ files:
451
458
  - lib/samagotchi/tool_declarations.rb
452
459
  - lib/samagotchi/tool_ids.rb
453
460
  - lib/samagotchi/tool_response.rb
461
+ - lib/samagotchi/tool_row_fields.rb
454
462
  - lib/samagotchi/tool_runner.rb
455
463
  - lib/samagotchi/tool_view.rb
456
464
  - lib/samagotchi/tools/args.rb
@@ -500,6 +508,7 @@ files:
500
508
  - lib/samagotchi/waiting_steer.rb
501
509
  - lib/samagotchi/web/app.rb
502
510
  - lib/samagotchi/web/capabilities.rb
511
+ - lib/samagotchi/web/editor.rb
503
512
  - lib/samagotchi/web/info_probe.rb
504
513
  - lib/samagotchi/web/lan.rb
505
514
  - lib/samagotchi/web/markdown_renderer.rb
@@ -515,6 +524,8 @@ files:
515
524
  - lib/samagotchi/web/public/command_complete.js
516
525
  - lib/samagotchi/web/public/command_list.js
517
526
  - lib/samagotchi/web/public/command_view.js
527
+ - lib/samagotchi/web/public/commands.js
528
+ - lib/samagotchi/web/public/composer.js
518
529
  - lib/samagotchi/web/public/composer_size.js
519
530
  - lib/samagotchi/web/public/context_chips.js
520
531
  - lib/samagotchi/web/public/copy.js
@@ -526,8 +537,10 @@ files:
526
537
  - lib/samagotchi/web/public/hold.js
527
538
  - lib/samagotchi/web/public/images.js
528
539
  - lib/samagotchi/web/public/index.html
540
+ - lib/samagotchi/web/public/info_bar.js
529
541
  - lib/samagotchi/web/public/init_row.js
530
542
  - lib/samagotchi/web/public/kept_commands.js
543
+ - lib/samagotchi/web/public/lists_view.js
531
544
  - lib/samagotchi/web/public/live_timing.js
532
545
  - lib/samagotchi/web/public/live_turn.js
533
546
  - lib/samagotchi/web/public/llm_context_chip.js
@@ -538,6 +551,8 @@ files:
538
551
  - lib/samagotchi/web/public/prompt_history.js
539
552
  - lib/samagotchi/web/public/question_card.js
540
553
  - lib/samagotchi/web/public/question_cards.js
554
+ - lib/samagotchi/web/public/refs.js
555
+ - lib/samagotchi/web/public/restore.js
541
556
  - lib/samagotchi/web/public/route.js
542
557
  - lib/samagotchi/web/public/scope.js
543
558
  - lib/samagotchi/web/public/scroll.js
@@ -546,9 +561,9 @@ files:
546
561
  - lib/samagotchi/web/public/session_title.js
547
562
  - lib/samagotchi/web/public/sessions_list.js
548
563
  - lib/samagotchi/web/public/shown_texts.js
549
- - lib/samagotchi/web/public/stable_html.js
550
564
  - lib/samagotchi/web/public/stage_model.js
551
565
  - lib/samagotchi/web/public/stage_view.js
566
+ - lib/samagotchi/web/public/stream_handlers.js
552
567
  - lib/samagotchi/web/public/strip.js
553
568
  - lib/samagotchi/web/public/tally.js
554
569
  - lib/samagotchi/web/public/task_stop.js
@@ -1,18 +0,0 @@
1
- // A block redrawn every tick (the info bar, once a second while a turn
2
- // runs) keeps its nodes: +html+ is written only when it changed, so a
3
- // shown tooltip stays up and a press on one of its buttons isn't lost to a
4
- // rewrite between mousedown and mouseup. +live+ ({selector: text}) names
5
- // the parts that change every tick (a running clock): +html+ carries them
6
- // empty and only their text is set, so a tick alone rewrites nothing else.
7
- const shown = new WeakMap();
8
-
9
- export function renderStable(el, html, live = {}) {
10
- if (shown.get(el) !== html) {
11
- el.innerHTML = html;
12
- shown.set(el, html);
13
- }
14
- for (const [selector, text] of Object.entries(live)) {
15
- const part = el.querySelector(selector);
16
- if (part && part.textContent !== text) part.textContent = text;
17
- }
18
- }