samagotchi 0.65.0 → 0.66.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/cli.md +11 -1
  6. data/docs/configuration.md +51 -7
  7. data/docs/context.md +4 -1
  8. data/docs/internals/engine.md +4 -1
  9. data/docs/internals/prompt-caching.md +10 -2
  10. data/docs/memory.md +1 -1
  11. data/docs/plugins.md +163 -2
  12. data/docs/releasing.md +1 -1
  13. data/docs/sessions.md +10 -0
  14. data/docs/sub-agent.md +3 -1
  15. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
  17. data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
  18. data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
  19. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
  20. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  21. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
  22. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  23. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  24. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  25. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  27. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  28. data/lib/samagotchi/cache_confirm.rb +9 -6
  29. data/lib/samagotchi/commands/registry.rb +25 -8
  30. data/lib/samagotchi/config.rb +8 -1
  31. data/lib/samagotchi/engine.rb +112 -4
  32. data/lib/samagotchi/kernel_loop.rb +9 -1
  33. data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
  34. data/lib/samagotchi/plugin/api.rb +80 -7
  35. data/lib/samagotchi/plugin/context.rb +22 -1
  36. data/lib/samagotchi/plugin/loader.rb +3 -2
  37. data/lib/samagotchi/send_command.rb +23 -7
  38. data/lib/samagotchi/session.rb +18 -1
  39. data/lib/samagotchi/session_commands.rb +16 -1
  40. data/lib/samagotchi/session_manager.rb +4 -1
  41. data/lib/samagotchi/session_setup.rb +14 -7
  42. data/lib/samagotchi/system_prompt.rb +16 -1
  43. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  44. data/lib/samagotchi/terminal_ui.rb +18 -2
  45. data/lib/samagotchi/tool_runner.rb +13 -1
  46. data/lib/samagotchi/toolkit.rb +246 -0
  47. data/lib/samagotchi/toolkit_command.rb +117 -0
  48. data/lib/samagotchi/tools/registry.rb +43 -6
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +12 -0
  51. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  52. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  53. data/lib/samagotchi/web/public/app.js +21 -7
  54. data/lib/samagotchi/web/public/commands.js +4 -2
  55. data/lib/samagotchi/web/public/index.html +9 -0
  56. data/lib/samagotchi/web/public/info_bar.js +5 -3
  57. data/lib/samagotchi/web/public/session_state.js +3 -0
  58. data/lib/samagotchi/web/public/stream_handlers.js +13 -0
  59. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  60. data/lib/samagotchi/worker.rb +2 -0
  61. metadata +10 -1
@@ -35,8 +35,15 @@ module Samagotchi
35
35
 
36
36
  def initialize
37
37
  @entries = {}
38
+ @toolkit = nil
38
39
  end
39
40
 
41
+ # The session's resolved toolkit (Toolkit::Resolved), which the lists
42
+ # below and #offered filter by; nil declares every tool (full). The
43
+ # Engine sets it at a turn's start, before the prompt is built, and
44
+ # only there: a request's declared tools never change mid-turn.
45
+ attr_accessor :toolkit
46
+
40
47
  # @return [Entry]
41
48
  def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, refs: nil, source: "core", layer: nil)
42
49
  raise ArgumentError, "tool #{name} is already registered" if @entries.key?(name)
@@ -59,19 +66,40 @@ module Samagotchi
59
66
  # @return [Entry, nil]
60
67
  def offered(name, layers: [])
61
68
  entry = self[name]
62
- entry if entry&.offered?(layers)
69
+ entry if entry&.offered?(layers) && declared?(entry)
70
+ end
71
+
72
+ # The entry +name+ names when the session's toolkit leaves it out (a
73
+ # real tool the model can't call here), nil otherwise.
74
+ # @return [Entry, nil]
75
+ def left_out(name, layers: [])
76
+ entry = self[name]
77
+ entry if entry&.offered?(layers) && !declared?(entry)
78
+ end
79
+
80
+ # The tools +toolkit+ (the session's by default) leaves out of a turn
81
+ # under +layers+.
82
+ # @return [Array<String>] in declaration order
83
+ def left_out_names(layers: [], toolkit: @toolkit)
84
+ ordered(@entries.values.select { |entry| entry.offered?(layers) && !declared?(entry, toolkit) }).map(&:name)
63
85
  end
64
86
 
87
+ # Every tool +source+ registered, declared or not (a plugin's tool set
88
+ # changing: Plugin::Api.apply_tools).
89
+ # @return [Array<Entry>]
90
+ def by_source(source) = @entries.values.select { |entry| entry.source == source }
91
+
65
92
  # The tools a turn offers is per turn: +layers+, the turn's LLM
66
93
  # context layers (LLMContextStrategy), adds the tools gated by them.
67
94
  # Without layers (none, the default) the list is what it always was.
68
95
  # @return [Array<String>] in declaration order
69
- def names(layers: []) = entries(layers: layers).map(&:name)
96
+ def names(layers: [], toolkit: @toolkit) = entries(layers: layers, toolkit: toolkit).map(&:name)
70
97
 
71
- # @return [Array<Entry>] in declaration order
72
- def entries(layers: [])
73
- core, added = @entries.values.select { |entry| entry.offered?(layers) }.partition(&:core?)
74
- core + added.sort_by { |entry| [entry.source.to_s, entry.name] }
98
+ # @return [Array<Entry>] in declaration order: the ones the turn's
99
+ # layers offer and the session's toolkit declares
100
+ # (+toolkit+: another one's, for what a change would declare)
101
+ def entries(layers: [], toolkit: @toolkit)
102
+ ordered(@entries.values.select { |entry| entry.offered?(layers) && declared?(entry, toolkit) })
75
103
  end
76
104
 
77
105
  # @return [Array<Hash>] the schemas, in declaration order
@@ -81,6 +109,15 @@ module Samagotchi
81
109
  @entries.freeze
82
110
  super
83
111
  end
112
+
113
+ private
114
+
115
+ def declared?(entry, toolkit = @toolkit) = toolkit.nil? || toolkit.declares?(entry)
116
+
117
+ def ordered(entries)
118
+ core, added = entries.partition(&:core?)
119
+ core + added.sort_by { |entry| [entry.source.to_s, entry.name] }
120
+ end
84
121
  end
85
122
  end
86
123
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.65.0"
4
+ VERSION = "0.66.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -566,6 +566,14 @@ module Samagotchi
566
566
  LLMContextStrategy.for_session(session, registry: host_registry)
567
567
  end
568
568
 
569
+ # A session's toolkit as its file names it ({name:, label:}), nil on
570
+ # full: without a worker nothing resolves it (a bundle's kits load in
571
+ # the worker), so the tools it leaves out aren't known here.
572
+ def toolkit_for(session)
573
+ toolkit = session.toolkit
574
+ toolkit && !toolkit.full? ? { name: toolkit.name, label: toolkit.label } : nil
575
+ end
576
+
569
577
  # A session's thinking level with where it came from and its own
570
578
  # (SettingExplained#summary), as its worker would resolve it; nil
571
579
  # when it can't be. Config and the registry only: no network.
@@ -848,6 +856,10 @@ module Samagotchi
848
856
  # native host), else the server's reading (an older worker, none).
849
857
  history = snapshot&.key?("reasoning_history") ? snapshot["reasoning_history"] : reasoning_history_for(session)
850
858
  session_json = session_json.merge(reasoning_history: history)
859
+ # Its toolkit when it isn't full (the tools chip): the worker's, which
860
+ # resolved it, else the name saved in the session file.
861
+ toolkit = snapshot&.key?("toolkit") ? snapshot["toolkit"] : toolkit_for(session)
862
+ session_json = session_json.merge(toolkit: toolkit)
851
863
  raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
852
864
  timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
853
865
  json_response(200, {
@@ -0,0 +1,109 @@
1
+ // Annotation actions: a bundle's pills in the Annotate bar (chi.annotation,
2
+ // docs/plugins.md). Each prepares a command line of its plugin with the
3
+ // selection filled into its template under it:
4
+ //
5
+ // /claude validate
6
+ // From your thinking:
7
+ // > the quoted line
8
+ //
9
+ // and fills the composer with it (the note goes under), or sends it at once
10
+ // (send: true). The pills ride on the session's command listing (the
11
+ // worker's snapshot), so a muted bundle's go with its command. Pure: no DOM.
12
+ // A module of its own: static JS is cached at unversioned URLs, and a new
13
+ // export on an old module would blank the page.
14
+
15
+ import { quoteBlock } from "./annotations.js";
16
+
17
+ // How many plugin pills the bar shows; the rest go behind "⋯".
18
+ export const INLINE_ACTIONS = 3;
19
+
20
+ // The session's commands' annotations, flattened in listing order: {label,
21
+ // line, template, send, command, anytime, source}. anytime: the command
22
+ // runs mid-turn (a send pill needs it while a turn runs).
23
+ export function annotationActions(commands) {
24
+ const out = [];
25
+ for (const c of Array.isArray(commands) ? commands : []) {
26
+ if (!c || typeof c.name !== "string" || !Array.isArray(c.annotations)) continue;
27
+ for (const a of c.annotations) {
28
+ if (!a || typeof a.label !== "string" || !a.label || typeof a.line !== "string" || !a.line) continue;
29
+ out.push({
30
+ label: a.label,
31
+ line: a.line,
32
+ template: typeof a.template === "string" && a.template ? a.template : "{quote}",
33
+ send: a.send === true,
34
+ command: c.name,
35
+ anytime: c.mid_turn === "anytime" || c.anytime === true,
36
+ source: typeof c.source === "string" ? c.source : "",
37
+ });
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+
43
+ // Whether two action lists draw the same pills (the bar redraws only on a
44
+ // change).
45
+ export function sameActions(a, b) {
46
+ return JSON.stringify(a) === JSON.stringify(b);
47
+ }
48
+
49
+ // The selection as templates read it: {quote} the labelled block Annotate
50
+ // makes (annotations.js quoteBlock), {text} the selection alone with the
51
+ // same trimming (CRLF → LF, each line's trailing blanks, blank lines at
52
+ // either end; indentation kept), {source} where it came from (answer,
53
+ // thinking, step, tool, user). null when it holds no text.
54
+ export function selectionFields(raw, source = {}) {
55
+ const quote = quoteBlock(raw, source);
56
+ if (!quote) return null;
57
+ const lines = String(raw ?? "").replace(/\r\n?/g, "\n").split("\n").map((l) => l.replace(/\s+$/, ""));
58
+ while (lines.length && !lines[0]) lines.shift();
59
+ while (lines.length && !lines[lines.length - 1]) lines.pop();
60
+ return { quote, text: lines.join("\n"), source: source.kind || "answer" };
61
+ }
62
+
63
+ // +template+ with {quote}, {text} and {source} filled from +fields+; any
64
+ // other {x} stays as written.
65
+ export function fillTemplate(template, fields) {
66
+ return String(template ?? "").replace(/\{(quote|text|source)\}/g, (_, key) => String(fields?.[key] ?? ""));
67
+ }
68
+
69
+ // The line the pill prepares: its command line, the filled template under it.
70
+ export function actionLine(action, fields) {
71
+ return `${action.line}\n${fillTemplate(action.template, fields)}`;
72
+ }
73
+
74
+ // The composer's text after a fill pill (+line+: actionLine's):
75
+ // - empty: the line;
76
+ // - it already starts with the same command line: the filled part is
77
+ // appended (several selections collect under one command);
78
+ // - another draft: the line on top, the draft under it, after a blank line
79
+ // (nothing leaves chi without a click on Send).
80
+ export function composerWithAction(value, action, line) {
81
+ const draft = String(value ?? "");
82
+ if (!draft.trim()) return line;
83
+ const filled = line.slice(action.line.length + 1);
84
+ if (draft.trimStart().split("\n")[0].trim() === action.line) {
85
+ const before = draft.replace(/\s+$/, "");
86
+ return `${before}\n\n${filled}`;
87
+ }
88
+ return `${line.replace(/\s+$/, "")}\n\n${draft.replace(/^\s*\n/, "")}`;
89
+ }
90
+
91
+ // Whether a pill can be clicked: a fill pill always; a send pill while a
92
+ // turn runs only when its command runs mid-turn (anytime), else it would come
93
+ // back busy.
94
+ export function actionEnabled(action, running) {
95
+ return !action.send || !running || action.anytime;
96
+ }
97
+
98
+ // A pill's tooltip: its label and bundle, that a send pill sends at once,
99
+ // and why it is disabled.
100
+ export function actionTitle(action, running) {
101
+ const base = action.source ? `${action.label} (${action.source})` : action.label;
102
+ if (!actionEnabled(action, running)) return `${base}: ${action.command} waits for the turn to end`;
103
+ return action.send ? `${base}: sends at once` : base;
104
+ }
105
+
106
+ // The pills shown inline and the ones behind "⋯".
107
+ export function splitInline(actions, max = INLINE_ACTIONS) {
108
+ return { inline: actions.slice(0, max), more: actions.slice(max) };
109
+ }
@@ -7,10 +7,15 @@
7
7
  // bubble rewrites its text every frame, which would collapse it by the time
8
8
  // a button is clicked.
9
9
  //
10
+ // A bundle's pills (chi.annotation) come after the presets: each prepares
11
+ // its command line with the selection (annotate_actions.js), at most 3
12
+ // inline, the rest behind "⋯".
13
+ //
10
14
  // The quoting rules are annotations.js'; this is the bar.
11
15
 
12
16
  import { annotateBarLeft, annotationSource, quoteBlock } from "./annotations.js";
13
17
  import { presetNote } from "./annotate_presets.js";
18
+ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline } from "./annotate_actions.js";
14
19
 
15
20
  // @param presets the preset texts (annotate_presets.js parsePresets)
16
21
  // @param canAnnotate () => whether the open session takes annotations (one
@@ -21,11 +26,18 @@ import { presetNote } from "./annotate_presets.js";
21
26
  // until its step closes)
22
27
  // @param onQuote (text) runs with the quote (and a preset's note) to append
23
28
  // to the composer; the selection is cleared and the bar hidden first
29
+ // @param actions () => the open session's annotation actions
30
+ // (annotate_actions.js annotationActions), read on every selection
31
+ // @param running () => whether a turn runs (a send pill may wait for it)
32
+ // @param onAction (action, fields) runs with a clicked pill's action and the
33
+ // selection's {quote, text, source}; the selection is cleared and the bar
34
+ // hidden first
24
35
  // @param doc, win the page's document and window
25
36
  // @return {schedule(), hide(), el}; schedule() re-reads the selection on the
26
37
  // next frame (call it on scroll)
27
38
  export function createAnnotateBar({
28
- presets, canAnnotate, roots, isLive, onQuote, doc = globalThis.document, win = globalThis.window,
39
+ presets, canAnnotate, roots, isLive, onQuote, actions = () => [], running = () => false, onAction = () => {},
40
+ doc = globalThis.document, win = globalThis.window,
29
41
  }) {
30
42
  const bar = doc.createElement("div");
31
43
  bar.id = "annotateBar";
@@ -46,11 +58,63 @@ export function createAnnotateBar({
46
58
  btn.title = text;
47
59
  bar.appendChild(btn);
48
60
  });
61
+ // The plugin pills: a separator, the inline ones, then ⋯ and the list
62
+ // it opens. Redrawn only when the session's actions change.
63
+ const group = doc.createElement("span");
64
+ group.className = "annotate-actions";
65
+ group.hidden = true;
66
+ bar.appendChild(group);
49
67
  doc.body.appendChild(bar);
50
68
  let pendingQuote = null;
69
+ let pendingFields = null;
70
+ let drawn = [];
71
+ let moreList = null;
72
+
73
+ function drawActions(list) {
74
+ drawn = list;
75
+ moreList = null;
76
+ group.hidden = !list.length;
77
+ if (!list.length) return group.replaceChildren();
78
+ const sep = doc.createElement("span");
79
+ sep.className = "annotate-sep";
80
+ sep.setAttribute("aria-hidden", "true");
81
+ const pill = (action, i) => {
82
+ const btn = button(action.label, "annotate-action");
83
+ btn.dataset.action = String(i);
84
+ if (action.send) btn.dataset.sends = "";
85
+ return btn;
86
+ };
87
+ const { inline, more } = splitInline(list);
88
+ const nodes = [sep, ...inline.map(pill)];
89
+ if (more.length) {
90
+ const toggle = button("⋯", "annotate-more");
91
+ toggle.title = "More actions";
92
+ toggle.setAttribute("aria-label", "More actions");
93
+ moreList = doc.createElement("div");
94
+ moreList.className = "annotate-more-list";
95
+ moreList.hidden = true;
96
+ more.forEach((action, j) => moreList.appendChild(pill(action, inline.length + j)));
97
+ nodes.push(toggle, moreList);
98
+ }
99
+ group.replaceChildren(...nodes);
100
+ }
101
+
102
+ // Each pill's state for whether a turn runs (a send pill may wait).
103
+ function markActions() {
104
+ const now = running();
105
+ const all = [...group.children, ...(moreList ? moreList.children : [])];
106
+ for (const btn of all) {
107
+ if (btn.dataset?.action === undefined) continue;
108
+ const action = drawn[Number(btn.dataset.action)];
109
+ btn.disabled = !actionEnabled(action, now);
110
+ btn.title = actionTitle(action, now);
111
+ }
112
+ }
51
113
 
52
114
  function hide() {
53
115
  pendingQuote = null;
116
+ pendingFields = null;
117
+ if (moreList) moreList.hidden = true;
54
118
  bar.hidden = true;
55
119
  }
56
120
 
@@ -67,9 +131,14 @@ export function createAnnotateBar({
67
131
  // Clip to where the selection starts: one tool row, one bubble.
68
132
  const clipped = range.cloneRange();
69
133
  if (!source.root.contains(range.endContainer)) clipped.setEnd(source.root, source.root.childNodes.length);
70
- const block = quoteBlock(clipped.toString(), source);
134
+ const raw = clipped.toString();
135
+ const block = quoteBlock(raw, source);
71
136
  if (!block) return hide();
72
137
  pendingQuote = block;
138
+ pendingFields = selectionFields(raw, source);
139
+ const list = actions() || [];
140
+ if (!sameActions(list, drawn)) drawActions(list);
141
+ markActions();
73
142
  const rects = clipped.getClientRects();
74
143
  const rect = rects.length ? rects[rects.length - 1] : clipped.getBoundingClientRect();
75
144
  bar.hidden = false;
@@ -109,6 +178,19 @@ export function createAnnotateBar({
109
178
  bar.addEventListener("click", (e) => {
110
179
  const btn = e.target.closest("button");
111
180
  if (!btn || !pendingQuote) return;
181
+ if (btn.className === "annotate-more") {
182
+ moreList.hidden = !moreList.hidden;
183
+ return;
184
+ }
185
+ if (btn.dataset.action !== undefined) {
186
+ const action = drawn[Number(btn.dataset.action)];
187
+ if (!action || btn.disabled) return;
188
+ const fields = pendingFields;
189
+ win.getSelection()?.removeAllRanges();
190
+ hide();
191
+ onAction(action, fields);
192
+ return;
193
+ }
112
194
  const preset = btn.dataset.index === undefined ? null : presets[Number(btn.dataset.index)];
113
195
  const text = preset ? presetNote(pendingQuote, preset) : pendingQuote;
114
196
  win.getSelection()?.removeAllRanges();
@@ -46,6 +46,7 @@ import { createTaskStopper } from "./task_stop.js";
46
46
  import { appendQuote } from "./annotations.js";
47
47
  import { parsePresets } from "./annotate_presets.js";
48
48
  import { createAnnotateBar } from "./annotate_bar.js";
49
+ import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
49
50
  import { copyText, installCopy } from "./copy.js";
50
51
  import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
51
52
  import { createQuestionCards } from "./question_cards.js";
@@ -1476,6 +1477,8 @@ function updateInfoBar() {
1476
1477
  llmContext: current.llmContext,
1477
1478
  thinking: current.thinking,
1478
1479
  reasoningHistory: current.reasoningHistory,
1480
+ // The tools chip (toolkit_chip.js): null on full.
1481
+ toolkit: current.toolkit,
1479
1482
  // The model notes the session's prompt carried.
1480
1483
  notes: promptNotesChip(current.promptNotes),
1481
1484
  memories: current.usedMemories,
@@ -2379,18 +2382,29 @@ function showToast(text, action = null, { sticky = false } = {}) {
2379
2382
  }
2380
2383
 
2381
2384
  // Annotate (annotate_bar.js): a selection in the conversation offers
2382
- // "Annotate" and the presets, which append a quote to the composer.
2385
+ // "Annotate" and the presets, which append a quote to the composer, and the
2386
+ // session's plugins' pills (chi.annotation, annotate_actions.js), which fill
2387
+ // the composer with their command line and the quote, or send it at once.
2388
+ function fillComposer(text) {
2389
+ promptEl.value = text;
2390
+ fitPrompt();
2391
+ promptEl.focus();
2392
+ promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
2393
+ promptEl.scrollTop = promptEl.scrollHeight;
2394
+ }
2395
+
2383
2396
  const annotateBar = createAnnotateBar({
2384
2397
  presets: parsePresets(document.body.dataset.annotatePresets),
2385
2398
  canAnnotate: () => !!selected && current.owner !== "tui",
2386
2399
  roots: turnRoots,
2387
2400
  isLive: (root) => liveTurn.running && turnDom.containsLive(root),
2388
- onQuote: (text) => {
2389
- promptEl.value = appendQuote(promptEl.value, text);
2390
- fitPrompt();
2391
- promptEl.focus();
2392
- promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
2393
- promptEl.scrollTop = promptEl.scrollHeight;
2401
+ onQuote: (text) => fillComposer(appendQuote(promptEl.value, text)),
2402
+ actions: () => annotationActions(current.commands),
2403
+ running: () => liveTurn.running,
2404
+ onAction: (action, fields) => {
2405
+ const line = actionLine(action, fields);
2406
+ if (action.send) runSessionCommand(line);
2407
+ else fillComposer(composerWithAction(promptEl.value, action, line));
2394
2408
  },
2395
2409
  });
2396
2410
  historyEl.addEventListener("scroll", annotateBar.schedule);
@@ -10,7 +10,8 @@ import { anchorInsertIndex, commandAnchor, isShellCommandLine, keepsCommandBubbl
10
10
  // Whether a composer line in an open session is one of its commands
11
11
  // (+commands+: the session GET's listing), as the terminals decide
12
12
  // (Commands::Registry#lookup): a "!" line with something after the "!", or
13
- // a listed name alone or followed by a space; a terminal's own (/stats,
13
+ // a listed name alone or followed by whitespace (a newline too: an
14
+ // annotation action's line puts the quote under the name); a terminal's own (/stats,
14
15
  // /exit) too, which the page answers itself (webLocalReply). An unknown
15
16
  // "/word" (a path, a typo) goes to the model as a prompt, as in a terminal.
16
17
  export function sessionCommandLine(text, commands) {
@@ -19,7 +20,8 @@ export function sessionCommandLine(text, commands) {
19
20
  if (!line.startsWith("/")) return false;
20
21
  if (webLocalReply(line)) return true;
21
22
  return (Array.isArray(commands) ? commands : [])
22
- .some((c) => c && typeof c.name === "string" && (line === c.name || line.startsWith(`${c.name} `)));
23
+ .some((c) => c && typeof c.name === "string"
24
+ && (line === c.name || (line.startsWith(c.name) && /\s/.test(line[c.name.length]))));
23
25
  }
24
26
 
25
27
  // /cut TEXT and /queue TEXT: the UIs' own words for a delivery (listed
@@ -480,6 +480,15 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
480
480
  #annotateBar{position:fixed;z-index:20;display:flex;flex-wrap:wrap;gap:4px;max-width:calc(100vw - 16px)}
481
481
  #annotateBar[hidden]{display:none}
482
482
  #annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px var(--shadow-strong)}
483
+ /* A bundle's pills (chi.annotation) after a thin separator; beyond 3, "⋯" opens the rest in a list under it. */
484
+ #annotateBar .annotate-actions{display:flex;flex-wrap:wrap;gap:4px;align-items:center;position:relative}
485
+ #annotateBar .annotate-actions[hidden]{display:none}
486
+ #annotateBar .annotate-sep{width:1px;align-self:stretch;margin:3px 2px;background:var(--border-strong)}
487
+ #annotateBar .annotate-action,#annotateBar .annotate-more{background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent-border);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
488
+ #annotateBar .annotate-action:disabled{opacity:.5;cursor:not-allowed}
489
+ #annotateBar .annotate-action[data-sends]::after{content:"↗";margin-left:4px;opacity:.75}
490
+ #annotateBar .annotate-more-list{position:absolute;top:calc(100% + 4px);right:0;display:flex;flex-direction:column;align-items:flex-end;gap:4px}
491
+ #annotateBar .annotate-more-list[hidden]{display:none}
483
492
  #annotateBar .annotate-preset{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
484
493
  /* Copy buttons (copy.js): top-right in an answer and a code block, left of a prompt (its right edge is the page's); shown on hover or focus, always (dim) on touch. They hold no text: "copied ✓" is CSS content. */
485
494
  .bubble.output,.bubble.user{position:relative}
@@ -1,6 +1,6 @@
1
1
  // The session bar's info line (#infoText): the open session's id, its copy
2
2
  // chi --attach button, status, model, chain links, parent and delegates,
3
- // llm ctx, thinking and reasoning history chips, model notes, memories, folder, ctx, speed,
3
+ // llm ctx, thinking, reasoning history and tools chips, model notes, memories, folder, ctx, speed,
4
4
  // session clock and the worker's version badge. A lit-html template over a
5
5
  // plain model app.js builds (updateInfoBar), so the markup lives here and
6
6
  // runs under node --test (spec/web/public/info_bar.test.js).
@@ -16,6 +16,7 @@ import { ALL_SESSIONS_HASH, sessionHash } from "./route.js";
16
16
  import { chipTemplate as llmContextChip } from "./llm_context_chip.js";
17
17
  import { chipTemplate as thinkingChip } from "./thinking_chip.js";
18
18
  import { chipTemplate as reasoningHistoryChip } from "./reasoning_history_chip.js";
19
+ import { chipTemplate as toolkitChip } from "./toolkit_chip.js";
19
20
 
20
21
  export const PREV = "←";
21
22
  export const NEXT = "→";
@@ -62,7 +63,8 @@ function kidsTemplate(kids) {
62
63
  // mismatch}: format.js modelLabel), chain ({prev, next}: chainLinkTemplate's
63
64
  // links or null), parent ({id, title} or null), kids (format.js
64
65
  // childrenSummary), llmContext, thinking and reasoningHistory (the chips'
65
- // summaries; reasoningHistory null on a native host: no chip), notes
66
+ // summaries; reasoningHistory null on a native host: no chip), toolkit
67
+ // (the tools chip's summary, null on full: no chip), notes
66
68
  // ({text, title} or null), memories (the used names), dir, ctx ({text,
67
69
  // tip}), speed ({text, tip} or null), clock (the session time's text, ""
68
70
  // for none), worker ({badge, busy} or null).
@@ -70,7 +72,7 @@ export function infoBarHtml(bar) {
70
72
  if (!bar) return nothing;
71
73
  const attach = `chi --attach ${bar.id}`;
72
74
  const memories = bar.memories || [];
73
- return html`<div class="meta"><span class="id" title=${bar.id}>${String(bar.id).slice(0, 8)}</span><button type="button" class="copy-attach" title=${`Copy "${attach}" to attach a terminal`}>${bar.copied ? "copied ✓" : "copy chi --attach"}</button><span class=${bar.status ? `status ${bar.status}` : "status"}>${bar.status || ""}</span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>delegated by ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${llmContextChip(bar.llmContext)}${thinkingChip(bar.thinking)}${reasoningHistoryChip(bar.reasoningHistory)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class="model speed" title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}${bar.clock ? html`<span class="model session-time">${bar.clock}</span>` : nothing}${workerBadgeTemplate(bar.worker)}</div>`;
75
+ return html`<div class="meta"><span class="id" title=${bar.id}>${String(bar.id).slice(0, 8)}</span><button type="button" class="copy-attach" title=${`Copy "${attach}" to attach a terminal`}>${bar.copied ? "copied ✓" : "copy chi --attach"}</button><span class=${bar.status ? `status ${bar.status}` : "status"}>${bar.status || ""}</span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>delegated by ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${llmContextChip(bar.llmContext)}${thinkingChip(bar.thinking)}${reasoningHistoryChip(bar.reasoningHistory)}${toolkitChip(bar.toolkit)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class="model speed" title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}${bar.clock ? html`<span class="model session-time">${bar.clock}</span>` : nothing}${workerBadgeTemplate(bar.worker)}</div>`;
74
76
  }
75
77
 
76
78
  // Draws +bar+ into +el+ (#infoText), with the memories in its tooltip.
@@ -48,6 +48,7 @@ export function emptySessionState() {
48
48
  // Which of its reasoning the model gets back from the next turn on, with
49
49
  // its source (the reasoning history chip), or null (a native host).
50
50
  reasoningHistory: null,
51
+ toolkit: null,
51
52
  timing: normalizeTiming(),
52
53
  };
53
54
  }
@@ -83,6 +84,8 @@ export function sessionStateFrom(data) {
83
84
  state.thinking = s.thinking || null;
84
85
  // null on a native host (it ignores the level) and from an older worker.
85
86
  state.reasoningHistory = s.reasoning_history || null;
87
+ // The tools chip's (null on full).
88
+ state.toolkit = s.toolkit || null;
86
89
  state.parentId = s.parent_id || null;
87
90
  state.continues = s.continues || null;
88
91
  state.commands = Array.isArray(data.commands) ? data.commands : [];
@@ -229,6 +229,11 @@ export function createStreamHandlers(deps) {
229
229
  updateInfoBar();
230
230
  reasoningHistoryControl.refresh();
231
231
  }
232
+ // /toolkit changed what the next turn declares; null: full (no chip).
233
+ if ("toolkit" in data) {
234
+ state().toolkit = data.toolkit || null;
235
+ updateInfoBar();
236
+ }
232
237
  if (view.resync) await resync();
233
238
  // A card's action that isn't anytime (the step-limit question's
234
239
  // /continue) has no bubble: nothing to remove. An anytime or queued
@@ -246,6 +251,9 @@ export function createStreamHandlers(deps) {
246
251
  // message renders as one) — no second bubble, or the output shows twice.
247
252
  else if (view.resync && historyHasCommandBubble(findInTurnRoots(".bubble.command"), view.line)) { /* drawn by the resync */ }
248
253
  else addCommandBubble(view);
254
+ // This tab's command refused as busy (a plugin's command mid-turn): the
255
+ // composer was cleared when it went; its line comes back to send later.
256
+ if (view.own && view.busy && composer.empty()) composer.refill(view.line);
249
257
  },
250
258
  // An anytime command (/help, a plugin's /btw) runs at once, beside a
251
259
  // turn: its bubble goes here, so the cards it shows come after it. One
@@ -301,6 +309,11 @@ export function createStreamHandlers(deps) {
301
309
  // shows elsewhere (a hook's links in the rendered answer) isn't drawn,
302
310
  // not even in the stage trail; a joined turn's notices come here too.
303
311
  hook_notice: (data) => {
312
+ // A plugin put the session on another toolkit (ctx.use_toolkit).
313
+ if ("toolkit" in data) {
314
+ state().toolkit = data.toolkit || null;
315
+ updateInfoBar();
316
+ }
304
317
  if (shownElsewhere(data, capabilities())) return;
305
318
  if (!turnDom.notice(data)) showHookNotice(data);
306
319
  },
@@ -0,0 +1,29 @@
1
+ // The session bar's tools chip: the session's toolkit when it isn't full
2
+ // ("tools lean"), with the tools it leaves out and how to change them in
3
+ // its tooltip. The summary is the session's toolkit ({name, label,
4
+ // left_out, fallback}: Toolkit::Explained#summary; the worker's snapshot or
5
+ // a command_ran; the server's own reading of the session file has no
6
+ // left_out). Changing it is /toolkit (no popover yet).
7
+
8
+ import { html, nothing } from "./vendor/lit-html.js";
9
+
10
+ // The chip's text and tooltip; null without a summary (full).
11
+ export function chipModel(summary) {
12
+ if (!summary) return null;
13
+ const label = summary.label || summary.name;
14
+ const parts = [`Toolkit: ${label}, the tools this session declares.`];
15
+ if (summary.fallback) parts.push(`${summary.fallback}: every tool is declared (full) until it resolves.`);
16
+ if (Array.isArray(summary.left_out)) {
17
+ parts.push(summary.left_out.length ? `Not loaded: ${summary.left_out.join(", ")}.` : "Nothing left out.");
18
+ }
19
+ parts.push("/toolkit lists them; /toolkit NAME, add TOOL or remove TOOL changes them from the next turn.");
20
+ return { text: `tools ${label}${summary.fallback ? " ?" : ""}`, title: parts.join(" ") };
21
+ }
22
+
23
+ // The session bar's chip (a lit-html template; info_bar.js), nothing on
24
+ // full.
25
+ export function chipTemplate(summary) {
26
+ const chip = chipModel(summary);
27
+ if (!chip) return nothing;
28
+ return html`<span class="model tools-chip" title=${chip.title}>${chip.text}</span>`;
29
+ }
@@ -878,6 +878,8 @@ module Samagotchi
878
878
  # /reasoning-history or /model: the level the next turn runs under
879
879
  # (the web's chip); nil on a native host, which ignores it.
880
880
  event[:reasoning_history] = @engine.reasoning_history_explained&.summary if changed.intersect?(%i[reasoning_history model])
881
+ # /toolkit: what the next turn declares (the web's chip; nil on full).
882
+ event[:toolkit] = @engine.toolkit_summary if changed.include?(:toolkit)
881
883
  event[:confirm] = confirm.to_h if confirm
882
884
  event[:output_truncated] = true if text.length > COMMAND_OUTPUT_LIMIT
883
885
  @engine.announce(event)
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.65.0
4
+ version: 0.66.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -190,6 +190,11 @@ files:
190
190
  - lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md
191
191
  - lib/samagotchi/bundles/claude-peer/scripts/pair
192
192
  - lib/samagotchi/bundles/claude-peer/skill_claude_peer.md
193
+ - lib/samagotchi/bundles/codex-peer/manifest.yml
194
+ - lib/samagotchi/bundles/codex-peer/plugin.rb
195
+ - lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md
196
+ - lib/samagotchi/bundles/codex-peer/scripts/pair
197
+ - lib/samagotchi/bundles/codex-peer/skill_codex_peer.md
193
198
  - lib/samagotchi/bundles/coordinator/manifest.yml
194
199
  - lib/samagotchi/bundles/coordinator/plugin.rb
195
200
  - lib/samagotchi/bundles/coordinator/skill_coordinator.md
@@ -489,6 +494,8 @@ files:
489
494
  - lib/samagotchi/tool_row_fields.rb
490
495
  - lib/samagotchi/tool_runner.rb
491
496
  - lib/samagotchi/tool_view.rb
497
+ - lib/samagotchi/toolkit.rb
498
+ - lib/samagotchi/toolkit_command.rb
492
499
  - lib/samagotchi/tools/args.rb
493
500
  - lib/samagotchi/tools/ask_user_question.rb
494
501
  - lib/samagotchi/tools/builtin_calls.rb
@@ -543,6 +550,7 @@ files:
543
550
  - lib/samagotchi/web/markdown_renderer.rb
544
551
  - lib/samagotchi/web/message_parts.rb
545
552
  - lib/samagotchi/web/public/activity.js
553
+ - lib/samagotchi/web/public/annotate_actions.js
546
554
  - lib/samagotchi/web/public/annotate_bar.js
547
555
  - lib/samagotchi/web/public/annotate_presets.js
548
556
  - lib/samagotchi/web/public/annotations.js
@@ -608,6 +616,7 @@ files:
608
616
  - lib/samagotchi/web/public/thinking_chip.js
609
617
  - lib/samagotchi/web/public/thinking_ticker.js
610
618
  - lib/samagotchi/web/public/timing.js
619
+ - lib/samagotchi/web/public/toolkit_chip.js
611
620
  - lib/samagotchi/web/public/turn_events.js
612
621
  - lib/samagotchi/web/public/turn_html.js
613
622
  - lib/samagotchi/web/public/turn_model.js