samagotchi 0.58.0 → 0.60.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/README.md +3 -2
  4. data/bin/chi +23 -2
  5. data/docs/architecture.md +6 -4
  6. data/docs/cli.md +14 -7
  7. data/docs/configuration.md +96 -31
  8. data/docs/hooks.md +12 -6
  9. data/docs/internals/background-tasks.md +7 -0
  10. data/docs/internals/engine.md +20 -12
  11. data/docs/internals/gemma4-contract.md +1 -0
  12. data/docs/internals/prompt-caching.md +4 -3
  13. data/docs/internals/tool-guardrails.md +2 -1
  14. data/docs/memory.md +1 -1
  15. data/docs/plugins.md +75 -6
  16. data/docs/sessions.md +2 -2
  17. data/docs/sub-agent.md +2 -2
  18. data/docs/testing.md +4 -2
  19. data/lib/samagotchi/bootstrap_command.rb +2 -1
  20. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
  21. data/lib/samagotchi/bridge.rb +6 -2
  22. data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
  23. data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
  24. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  25. data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
  26. data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  28. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  29. data/lib/samagotchi/config.rb +44 -10
  30. data/lib/samagotchi/continue_offer.rb +5 -2
  31. data/lib/samagotchi/engine.rb +89 -15
  32. data/lib/samagotchi/host_config.rb +3 -1
  33. data/lib/samagotchi/host_registry.rb +2 -1
  34. data/lib/samagotchi/iteration_limit.rb +3 -2
  35. data/lib/samagotchi/kernel_loop.rb +35 -9
  36. data/lib/samagotchi/llm/backend.rb +6 -1
  37. data/lib/samagotchi/llm/chat_loop.rb +11 -6
  38. data/lib/samagotchi/llm/model_result.rb +11 -2
  39. data/lib/samagotchi/llm/native_backend.rb +3 -2
  40. data/lib/samagotchi/log_subscriber.rb +2 -1
  41. data/lib/samagotchi/model_settings.rb +4 -2
  42. data/lib/samagotchi/plugin/context.rb +16 -3
  43. data/lib/samagotchi/process_group.rb +76 -3
  44. data/lib/samagotchi/prompt_files.rb +127 -0
  45. data/lib/samagotchi/ref.rb +124 -0
  46. data/lib/samagotchi/send_command.rb +1 -1
  47. data/lib/samagotchi/session.rb +15 -3
  48. data/lib/samagotchi/session_commands.rb +15 -3
  49. data/lib/samagotchi/session_metrics.rb +8 -2
  50. data/lib/samagotchi/session_setup.rb +11 -5
  51. data/lib/samagotchi/steer_cut.rb +12 -8
  52. data/lib/samagotchi/steer_cut_after.rb +69 -0
  53. data/lib/samagotchi/system_prompt.rb +74 -18
  54. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  56. data/lib/samagotchi/terminal_ui/formatting.rb +23 -5
  57. data/lib/samagotchi/terminal_ui.rb +18 -6
  58. data/lib/samagotchi/tool_activity.rb +18 -1
  59. data/lib/samagotchi/tool_declarations.rb +1 -1
  60. data/lib/samagotchi/tools/execute.rb +318 -70
  61. data/lib/samagotchi/tools/task_get.rb +2 -0
  62. data/lib/samagotchi/tools/task_runtime.rb +96 -13
  63. data/lib/samagotchi/tools/task_wait.rb +23 -5
  64. data/lib/samagotchi/turn_flow.rb +22 -3
  65. data/lib/samagotchi/utf8_default.rb +1 -1
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/public/favicon.svg +1 -0
  68. data/lib/samagotchi/web/public/index.html +1 -0
  69. data/lib/samagotchi/web/public/read_view.js +3 -1
  70. data/lib/samagotchi/web/public/refs.js +107 -15
  71. data/lib/samagotchi/web/public/task_stop.js +25 -7
  72. data/lib/samagotchi/worker.rb +1 -0
  73. metadata +7 -1
@@ -10,6 +10,9 @@ module Samagotchi
10
10
  TAIL_LINES_DEFAULT = 10
11
11
  TAIL_LINES_MAX = 100
12
12
  POLL_INTERVAL = 0.5
13
+ # A handed-over execute keeps stderr in a log of its own: at most this
14
+ # many of its last lines follow the output tail.
15
+ STDERR_TAIL_LINES_MAX = 10
13
16
 
14
17
  def self.name = NAME
15
18
 
@@ -41,7 +44,7 @@ module Samagotchi
41
44
 
42
45
  if completion_pattern
43
46
  output_tail = output_tail(record, tail_lines)
44
- return format_response(record, wait_result: "pattern_matched", output_tail: output_tail) if completion_pattern.match?(output_tail)
47
+ return format_response(record, wait_result: "pattern_matched", output_tail: output_tail, tail_lines: tail_lines) if completion_pattern.match?(output_tail)
45
48
  end
46
49
 
47
50
  return cancelled_response(task_id, tail_lines, started) if cancelled.call
@@ -55,7 +58,7 @@ module Samagotchi
55
58
  refreshed, = TaskRuntime.get_record(task_id)
56
59
  return finished_response(refreshed, tail_lines) unless refreshed.fetch("status") == "running"
57
60
 
58
- format_response(refreshed, wait_result: "timeout", output_tail: output_tail(refreshed, tail_lines))
61
+ format_response(refreshed, wait_result: "timeout", output_tail: output_tail(refreshed, tail_lines), tail_lines: tail_lines)
59
62
  rescue StandardError => e
60
63
  "Error: #{e.message}"
61
64
  end
@@ -72,7 +75,8 @@ module Samagotchi
72
75
  " To keep waiting: task_wait #{task_id}. If it is stuck or no longer needed: task_stop #{task_id}.",
73
76
  "waited: #{(monotonic_time - started).round}s"
74
77
  ]
75
- format_response(record, wait_result: "canceled", output_tail: output_tail(record, tail_lines), extra: note)
78
+ format_response(record, wait_result: "canceled", output_tail: output_tail(record, tail_lines), tail_lines: tail_lines,
79
+ extra: note)
76
80
  end
77
81
  private_class_method :cancelled_response
78
82
 
@@ -81,7 +85,8 @@ module Samagotchi
81
85
  def self.finished_response(record, tail_lines)
82
86
  return format_response(record) unless TaskRuntime.stopped_by_user?(record)
83
87
 
84
- format_response(record, output_tail: output_tail(record, tail_lines), extra: [TaskRuntime::USER_STOP_NOTE])
88
+ format_response(record, output_tail: output_tail(record, tail_lines), tail_lines: tail_lines,
89
+ extra: [TaskRuntime::USER_STOP_NOTE])
85
90
  end
86
91
  private_class_method :finished_response
87
92
 
@@ -90,7 +95,17 @@ module Samagotchi
90
95
  end
91
96
  private_class_method :output_tail
92
97
 
93
- def self.format_response(record, wait_result: nil, output_tail: nil, extra: [])
98
+ # The last lines of a handed-over execute's stderr log (stderr_path),
99
+ # "" for a task_create task (one log) or an empty stderr.
100
+ def self.stderr_tail(record, tail_lines)
101
+ path = record["stderr_path"]
102
+ return "" if path.to_s.empty? || tail_lines.nil?
103
+
104
+ TaskRuntime.output_tail_lines(path, [tail_lines, STDERR_TAIL_LINES_MAX].min)
105
+ end
106
+ private_class_method :stderr_tail
107
+
108
+ def self.format_response(record, wait_result: nil, output_tail: nil, tail_lines: nil, extra: [])
94
109
  lines = [
95
110
  "task_id: #{record.fetch("id")}",
96
111
  "status: #{record.fetch("status")}",
@@ -98,12 +113,15 @@ module Samagotchi
98
113
  "stop_reason: #{record["stop_reason"]}",
99
114
  "output_path: #{record.fetch("output_path")}"
100
115
  ]
116
+ lines << "stderr_path: #{record["stderr_path"]}" if record["stderr_path"]
101
117
  return lines.join("\n") unless output_tail
102
118
 
103
119
  lines << "wait_result: #{wait_result}" if wait_result
104
120
  lines.concat(extra)
105
121
  lines << "output_tail:"
106
122
  lines << output_tail.rstrip unless output_tail.empty?
123
+ stderr_tail = stderr_tail(record, tail_lines)
124
+ lines.concat(["stderr_tail:", stderr_tail.rstrip]) unless stderr_tail.empty?
107
125
  lines.join("\n")
108
126
  end
109
127
  private_class_method :format_response
@@ -42,8 +42,9 @@ module Samagotchi
42
42
  [:invalid, nil]
43
43
  end
44
44
 
45
- # @return [Hash, nil] the pending continue offer: {context:, no_interrupt:}
46
- # (context summarizes the interrupted turn for a "no, <reason>")
45
+ # @return [Hash, nil] the pending continue offer: {context:, no_interrupt:,
46
+ # run_start:, model:} (context summarizes the interrupted turn for a
47
+ # "no, <reason>"; run_start and model are #resume_from's)
47
48
  attr_reader :offer
48
49
 
49
50
  # How many times the conversation went back to a checkpoint (a failed
@@ -94,7 +95,11 @@ module Samagotchi
94
95
  end
95
96
 
96
97
  if result.resumable?
97
- @offer = { context: interrupted_turn_context(result), no_interrupt: no_interrupt }
98
+ # A Continue that ran out again goes on from the run the chain
99
+ # started; any other turn's offer (a prompt's, a wake's) from its own.
100
+ run_start = (continue && resume_from) || result.run_start
101
+ @offer = { context: interrupted_turn_context(result), no_interrupt: no_interrupt, run_start: run_start,
102
+ model: @engine.effective_model_name }
98
103
  :continue_offered
99
104
  else
100
105
  @offer = nil
@@ -143,6 +148,20 @@ module Samagotchi
143
148
  :restored
144
149
  end
145
150
 
151
+ # Where a Continue of the pending offer resumes: the run its turn (or
152
+ # the first turn of a chain of Continues) started (ModelResult#run_start),
153
+ # so the Continue keeps that run's reasoning (Engine#run_turn
154
+ # resume_from:). nil with no offer, and after a model change while the
155
+ # offer stands: another model's reasoning isn't replayed, the Continue
156
+ # starts cold. A cancelled or failed Continue keeps the offer, and so
157
+ # this; every way the offer goes takes it along.
158
+ # @return [Integer, nil]
159
+ def resume_from
160
+ return nil unless @offer && @offer[:model] == @engine.effective_model_name
161
+
162
+ @offer[:run_start]
163
+ end
164
+
146
165
  # Answer the offer with no (Stop): the interrupted turn stays, as
147
166
  # after a Ctrl-C or a new prompt (!rollback still erases it), and the
148
167
  # model reads that it wasn't continued: a note, or the user's reason
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- # chi's files (memories, AGENT.md, sessions, config.yml) are UTF-8,
4
+ # chi's files (memories, AGENTS.md, sessions, config.yml) are UTF-8,
5
5
  # whatever the locale. Under LC_ALL=C (or with no locale at all: an app
6
6
  # started from Finder or launchd, `env -i`) Ruby reads files as US-ASCII,
7
7
  # and a UTF-8 memory index then breaks the system prompt
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.58.0"
4
+ VERSION = "0.60.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7aa5ff"/><stop offset="1" stop-color="#8957e5"/></linearGradient></defs><rect x="1" y="1" width="30" height="30" rx="7.5" fill="url(#g)"/><g fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.8 9.6c2.3-1.3 4-.7 5.2 1.4l6 10.1c1.2 2.1 2.9 2.7 5.2 1.4"/><path d="M22.6 9.2 9.4 22.8"/></g></svg>
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <title>Chi</title>
6
+ <link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
6
7
  <meta name="viewport" content="width=device-width,initial-scale=1">
7
8
  <style>
8
9
  :root{color-scheme:dark;--bg:#0b0d11;--bg-panel:#151821;--bg-inset:#0f1115;--bg-raised:#22262f;--bg-hover:#1b1f27;--border:#1f232b;--border-strong:#2d333f;--text:#e6e8eb;--text-bright:#f0f3f6;--text-2:#c9d1d9;--text-3:#9aa4b2;--text-muted:#8b949e;--text-faint:#6e7681;--accent:#7aa5ff;--accent-strong:#1f6feb;--accent-bg:#1b2336;--accent-ring:rgba(122,165,255,.18);--primary:#2a68d1;--ok:#3fb950;--ok-text:#7ee787;--ok-bg:#1a2e1a;--warn:#d29922;--warn-text:#e3b341;--warn-bright:#f2cc60;--warn-bg:#2b2108;--warn-border:#6b5416;--danger:#f85149;--danger-bg:#da3633;--danger-text:#ff7b72;--danger-bg-dim:#3d1f1f;--think:#8957e5;--think-text:#d2a8ff;--accent-border:#223150;--pick-border:#79c0ff;--accent-tint:color-mix(in srgb,var(--accent) 7%,transparent);--narr:color-mix(in srgb,var(--accent) 38%,var(--text-muted));
@@ -67,8 +67,10 @@ export function readLinesHtml(row) {
67
67
  const first = numbered[0].line;
68
68
  const last = numbered.at(-1).line;
69
69
  const label = first === last ? `line ${first}` : `lines ${first}–${last}`;
70
+ // A blank line of the file holds no text, only a <br>: an empty block
71
+ // drops out of a copied selection, a space would be copied as one.
70
72
  const spans = lines.map(({ text, line }) => line === null
71
73
  ? `<span class="read-line read-meta">${escapeHtml(text)}</span>`
72
- : `<span class="read-line" data-line="${line}">${escapeHtml(text) || " "}</span>`).join("");
74
+ : `<span class="read-line" data-line="${line}">${escapeHtml(text) || "<br>"}</span>`).join("");
73
75
  return `<details class="activity-read"><summary>${label}</summary><pre class="read-lines"><code>${spans}</code></pre></details>`;
74
76
  }
@@ -6,6 +6,11 @@
6
6
  // an answer or a step's text (ref_scan.js; a link whose text is one too).
7
7
  // The DOM carries data-ref-kind and the target's attributes, never what a
8
8
  // click does: those are the ref's targets, kept per kind in REF_ACTIONS.
9
+ // A plugin's ref (the server's Ref: {kind, target, label?, actions}) is
10
+ // bound to its element by bindRef: its label and its URL actions live in a
11
+ // WeakMap off the DOM, and refTarget reads them first. Its actions join the
12
+ // kind's own (the `actions` entry); a kind the page has no table for runs
13
+ // only those.
9
14
  // A plain click runs the first one available (the main target); a ref
10
15
  // with more shows a ▾ on hover that opens a menu of all of them
11
16
  // (ref_menu.js), and a tap opens it. A ref with a target is a tab stop
@@ -26,6 +31,55 @@ export function refAttrs(ref) {
26
31
  return ` data-ref-kind="${escapeHtml(ref.kind)}"${attr("path", ref.path)}${attr("line", ref.line)}${attr("end-line", ref.end_line)}`;
27
32
  }
28
33
 
34
+ // A kind, as the server's Ref allows it (Ref::KIND).
35
+ const KIND = /^[a-z][a-z0-9_-]{0,31}$/;
36
+
37
+ // A plugin's ref data per element (bindRef): {kind, target, label,
38
+ // actions}. A WeakMap, so a redrawn element takes its data with it.
39
+ const REF_DATA = new WeakMap();
40
+
41
+ // +url+ as the page opens it when it is an http(s) one, else null. The
42
+ // server checks it too (Ref, RefAction); the page never opens another
43
+ // scheme a plugin gave (javascript:, data:, an editor's).
44
+ export function httpUrl(url) {
45
+ if (typeof url !== "string") return null;
46
+ try {
47
+ const parsed = new URL(url.trim());
48
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ // Makes +el+ the ref +ref+ (the server's Ref#to_h): the ref class and the
55
+ // data attributes every ref carries (a file's path and lines, as refAttrs),
56
+ // its label and its actions in the WeakMap. An action without an id, a
57
+ // label or an http(s) URL is dropped; a kind the server's Ref refuses binds
58
+ // nothing. A rebind replaces the target attributes. +el+, or null without one.
59
+ export function bindRef(el, ref) {
60
+ if (!el) return null;
61
+ if (typeof ref?.kind !== "string" || !KIND.test(ref.kind)) return el;
62
+ const target = ref.target || {};
63
+ el.classList.add("ref");
64
+ el.dataset.refKind = ref.kind;
65
+ for (const [name, value] of [["refPath", target.path], ["refLine", target.line], ["refEndLine", target.end_line]]) {
66
+ if (value) el.dataset[name] = String(value);
67
+ else delete el.dataset[name];
68
+ }
69
+ const actions = (Array.isArray(ref.actions) ? ref.actions : []).flatMap((action) => {
70
+ const url = httpUrl(action?.url);
71
+ return url && action.id && action.label ? [{ id: String(action.id), label: String(action.label), url }] : [];
72
+ });
73
+ REF_DATA.set(el, { kind: ref.kind, target, label: ref.label || null, actions });
74
+ return el;
75
+ }
76
+
77
+ // The same URL either way (a link's href against an action's URL).
78
+ function sameUrl(a, b) {
79
+ const url = httpUrl(a);
80
+ return !!url && url === httpUrl(b);
81
+ }
82
+
29
83
  // The URL +template+ ({path}, {line}; web.editor) opens +path+ at +line+
30
84
  // (1 when none) with: each path segment encoded, the slashes kept. "" with
31
85
  // no template or path.
@@ -41,16 +95,38 @@ export function editorUrl(template, path, line) {
41
95
  // editorUrl}, the server's capabilities.editor and editor_url) and
42
96
  // +target+ (refTarget's). A secondary one (copy) joins a ref that has
43
97
  // another, never alone. A line (a diff or read line) has its click only.
98
+ // A link whose text is the ref (github-pr's PR diff): its href stays the
99
+ // plain click's, which the browser follows. Its label is the ref's action
100
+ // with the same URL (the menu shows them once), else the old guess from
101
+ // the URL (an old session's link has no ref data).
102
+ const LINK = Object.freeze({
103
+ id: "link",
104
+ label: (target) => target.actions?.find((action) => sameUrl(action.url, target.href))?.label ||
105
+ (/\/pull\/\d+\/files\b/.test(target.href) ? "PR diff" : "Open link"),
106
+ available: (_viewer, target) => !!target.href,
107
+ run: (target, _viewer, env) => env.openLink(target.href),
108
+ });
109
+
110
+ // The ref's own actions (a plugin's, bindRef), one target each, for every
111
+ // viewer (a URL opens on a phone too): a new tab with no opener and no
112
+ // referrer. One with the link's URL, or an earlier one's, isn't repeated.
113
+ // Not on a diff or read line (its click only), which shares its title's data.
114
+ const URL_ACTIONS = Object.freeze({
115
+ id: "actions",
116
+ expand: (target) => (target.actions || [])
117
+ .filter((action, at, all) => !sameUrl(action.url, target.href) && all.findIndex((other) => sameUrl(other.url, action.url)) === at)
118
+ .map((action) => Object.freeze({
119
+ id: `url:${action.id}`,
120
+ label: () => action.label,
121
+ available: (_viewer, target) => !target.isLine,
122
+ run: (_target, _viewer, env) => env.openLink(action.url),
123
+ hint: () => action.url,
124
+ })),
125
+ });
126
+
44
127
  export const REF_ACTIONS = Object.freeze({
45
128
  file: Object.freeze([
46
- // A link whose text is the ref (github-pr's PR diff): its href stays
47
- // the plain click's, which the browser follows.
48
- Object.freeze({
49
- id: "link",
50
- label: (target) => (/\/pull\/\d+\/files\b/.test(target.href) ? "PR diff" : "Open link"),
51
- available: (_viewer, target) => !!target.href,
52
- run: (target, _viewer, env) => env.openLink(target.href),
53
- }),
129
+ LINK,
54
130
  Object.freeze({
55
131
  id: "editor",
56
132
  label: () => "Open in editor",
@@ -63,6 +139,7 @@ export const REF_ACTIONS = Object.freeze({
63
139
  // The hover hint: it opens the file as it is now.
64
140
  hint: (target) => `Open the current file in your editor${target.line ? ` at line ${target.line}` : ""}`,
65
141
  }),
142
+ URL_ACTIONS,
66
143
  // The row that touched the file, in the turn: not for a row's own
67
144
  // title or lines (they are in the turn already).
68
145
  Object.freeze({
@@ -80,13 +157,18 @@ export const REF_ACTIONS = Object.freeze({
80
157
  run: (target, _viewer, env) => env.copy(target.path),
81
158
  }),
82
159
  ]),
160
+ // Any other kind (a plugin's issue, commit, …): its link, then its actions.
161
+ "*": Object.freeze([LINK, URL_ACTIONS]),
83
162
  });
84
163
 
85
164
  // The targets +viewer+ has for +target+ (refTarget's), in order; none
86
165
  // when only a secondary one is.
87
166
  export function refActions(target, viewer, actions = REF_ACTIONS) {
88
167
  if (!target) return [];
89
- const found = (actions[target.kind] || []).filter((action) => action.available(viewer, target));
168
+ // Own keys only: a kind named like an Object property ("constructor") is
169
+ // any other kind.
170
+ const table = (Object.hasOwn(actions, target.kind) ? actions[target.kind] : actions["*"]) || [];
171
+ const found = table.flatMap((action) => (action.expand ? action.expand(target) : [action])).filter((action) => action.available(viewer, target));
90
172
  return found.some((action) => !action.secondary) ? found : [];
91
173
  }
92
174
 
@@ -97,8 +179,9 @@ const OWNERS = ".activity-row, .bubble.approval";
97
179
  // A line of the owner's file: a diff line, or a read row's numbered line.
98
180
  const LINES = ".diff-line[data-line], .read-line[data-line]";
99
181
 
100
- // What a click on +el+ points at: {kind, path, line, owner, el, isLine,
101
- // href} or null. A row title (or an approval card's path) opens its file
182
+ // What a click on +el+ points at: {kind, path, id, label, actions, line,
183
+ // owner, el, isLine, href} or null (+id+, +label+ and +actions+ a plugin's
184
+ // ref's, bindRef: the WeakMap is read first). A row title (or an approval card's path) opens its file
102
185
  // at the lines the call named (an edit without any at its first changed
103
186
  // line); a diff line (or a hunk header) or a read row's numbered line, in
104
187
  // a row or a card with a ref, opens that file at that line. +el+ is the
@@ -109,7 +192,8 @@ export function refTarget(el) {
109
192
  if (title) {
110
193
  const owner = title.closest(OWNERS);
111
194
  const href = title.matches("a[href]") ? title.getAttribute("href") : null;
112
- return { ...targetOf(title), line: Number(title.dataset.refLine) || firstChange(owner), owner: owner || title, el: title, isLine: false, href };
195
+ const own = targetOf(title);
196
+ return { ...own, line: own.line || firstChange(owner), owner: owner || title, el: title, isLine: false, href };
113
197
  }
114
198
  const diffLine = el?.closest?.(LINES);
115
199
  const owner = diffLine?.closest(OWNERS);
@@ -119,7 +203,13 @@ export function refTarget(el) {
119
203
  }
120
204
 
121
205
  function targetOf(el) {
122
- return { kind: el.dataset.refKind, path: el.dataset.refPath || "" };
206
+ const data = REF_DATA.get(el);
207
+ if (!data) return { kind: el.dataset.refKind, path: el.dataset.refPath || "", line: Number(el.dataset.refLine) || null, id: null, label: null, actions: [] };
208
+ // Bound data is the whole ref: no fallback to the attributes.
209
+ const { target } = data;
210
+ return {
211
+ kind: data.kind, path: target.path || "", line: Number(target.line) || null, id: target.id ?? null, label: data.label, actions: data.actions,
212
+ };
123
213
  }
124
214
 
125
215
  // An edit/write without a line range opens at its first changed line (its
@@ -235,9 +325,11 @@ function defaultOpen(url) {
235
325
  else window.location.href = url;
236
326
  }
237
327
 
238
- // A link ref's href from the menu: a new tab, as the answer's links open.
328
+ // A link ref's href or a ref's URL action: a new tab, as the answer's
329
+ // links open, with no opener and no referrer (the page's LAN URL and
330
+ // session id stay here).
239
331
  function defaultOpenLink(href) {
240
- window.open(href, "_blank", "noopener");
332
+ window.open(href, "_blank", "noopener,noreferrer");
241
333
  }
242
334
 
243
335
  // One delegated listener set on +root+ for every ref on the page.
@@ -22,10 +22,30 @@ export function stopTarget(row, features) {
22
22
  // A task_wait row's title without its wait ("npm test · up to 600s").
23
23
  const WAIT_SUFFIX = / · up to \d+s$/;
24
24
 
25
- // The command a task_create of this turn started +taskId+ with (its row's
26
- // view), else a task_wait row's title for it (the server titles one by its
27
- // task's command, also for a task from an earlier turn: ToolActivity.
28
- // task_title), or null.
25
+ // The first line of an execute that was still running at its limit and
26
+ // went on as a task (Tools::Execute::HANDED_OVER), then its task_id line.
27
+ // Anchored at the start, an optional "ran as: …" line and "[execute]" lead
28
+ // before it: a "task_id:" line anywhere else is the command's own output.
29
+ // The twin of TaskRuntime.handed_over_id.
30
+ const HANDED_OVER = /^(?:ran as: [^\n]*\n)?(?:\[execute\]\n)?Still running at the \d+s limit:[^\n]*\ntask_id: (\S+)/;
31
+
32
+ // The task id an execute row's output hands over, else null.
33
+ export function handedOverId(output) {
34
+ return HANDED_OVER.exec(output || "")?.[1] || null;
35
+ }
36
+
37
+ // The task id a row of this turn started: a task_create's, or an execute's
38
+ // hand-over (handedOverId); else null.
39
+ function startedId(row) {
40
+ if (row.tool === "execute") return handedOverId(row.output);
41
+ if (row.tool !== "task_create") return null;
42
+ return /(?:^|\n)task_id: (\S+)/.exec(row.output || "")?.[1] || null;
43
+ }
44
+
45
+ // The command a task_create or a handed-over execute of this turn started
46
+ // +taskId+ with (its row's view), else a task_wait row's title for it (the
47
+ // server titles one by its task's command, also for a task from an earlier
48
+ // turn: ToolActivity.task_title), or null.
29
49
  export function taskCommand(gens, taskId) {
30
50
  let titled = null;
31
51
  for (const gen of gens || []) {
@@ -33,9 +53,7 @@ export function taskCommand(gens, taskId) {
33
53
  if (row.tool === "task_wait" && row.view?.task_id === taskId && row.title) {
34
54
  titled ||= row.title.replace(WAIT_SUFFIX, "");
35
55
  }
36
- if (row.tool !== "task_create") continue;
37
- const id = /(?:^|\n)task_id: (\S+)/.exec(row.output || "")?.[1];
38
- if (id === taskId) return row.view?.command || null;
56
+ if (startedId(row) === taskId) return row.view?.command || null;
39
57
  }
40
58
  }
41
59
  return titled;
@@ -367,6 +367,7 @@ module Samagotchi
367
367
  # (re)spawn.
368
368
  memories: @session.preloaded_memory_names,
369
369
  muted_memories: @session.muted_memory_names,
370
+ appended_prompt_files: @session.appended_prompt_files,
370
371
  reminders: {
371
372
  callback: lambda { |due_names|
372
373
  # A reminder is due: the loop runs a reminder turn for it once
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.58.0
4
+ version: 0.60.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -178,6 +178,8 @@ files:
178
178
  - lib/samagotchi/broadcast_command.rb
179
179
  - lib/samagotchi/bundle_command.rb
180
180
  - lib/samagotchi/bundle_needs.rb
181
+ - lib/samagotchi/bundles/agents-md/manifest.yml
182
+ - lib/samagotchi/bundles/agents-md/plugin.rb
181
183
  - lib/samagotchi/bundles/btw/manifest.yml
182
184
  - lib/samagotchi/bundles/btw/plugin.rb
183
185
  - lib/samagotchi/bundles/check-in/manifest.yml
@@ -392,6 +394,7 @@ files:
392
394
  - lib/samagotchi/process_group.rb
393
395
  - lib/samagotchi/project_scope.rb
394
396
  - lib/samagotchi/prompt.rb
397
+ - lib/samagotchi/prompt_files.rb
395
398
  - lib/samagotchi/prompt_history.rb
396
399
  - lib/samagotchi/prompt_literal_guard.rb
397
400
  - lib/samagotchi/prompt_note.rb
@@ -403,6 +406,7 @@ files:
403
406
  - lib/samagotchi/recap_setup.rb
404
407
  - lib/samagotchi/recap_store.rb
405
408
  - lib/samagotchi/redacted_inspect.rb
409
+ - lib/samagotchi/ref.rb
406
410
  - lib/samagotchi/relay.rb
407
411
  - lib/samagotchi/relay_desk.rb
408
412
  - lib/samagotchi/relay_verifier.rb
@@ -430,6 +434,7 @@ files:
430
434
  - lib/samagotchi/setting_explained.rb
431
435
  - lib/samagotchi/steer.rb
432
436
  - lib/samagotchi/steer_cut.rb
437
+ - lib/samagotchi/steer_cut_after.rb
433
438
  - lib/samagotchi/system_prompt.rb
434
439
  - lib/samagotchi/terminal_ui.rb
435
440
  - lib/samagotchi/terminal_ui/attach_launcher.rb
@@ -541,6 +546,7 @@ files:
541
546
  - lib/samagotchi/web/public/data.js
542
547
  - lib/samagotchi/web/public/diff_view.js
543
548
  - lib/samagotchi/web/public/families_view.js
549
+ - lib/samagotchi/web/public/favicon.svg
544
550
  - lib/samagotchi/web/public/format.js
545
551
  - lib/samagotchi/web/public/hold.js
546
552
  - lib/samagotchi/web/public/images.js