samagotchi 0.31.0 → 0.33.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/README.md +13 -0
  4. data/bin/chi +3 -0
  5. data/docs/cli.md +1 -0
  6. data/docs/configuration.md +3 -1
  7. data/docs/context.md +220 -0
  8. data/docs/guardrails.md +12 -4
  9. data/docs/hooks.md +7 -3
  10. data/docs/plugins.md +79 -2
  11. data/docs/releasing.md +3 -2
  12. data/lib/samagotchi/answer_command.rb +2 -1
  13. data/lib/samagotchi/archive_store.rb +1 -15
  14. data/lib/samagotchi/bootstrap_command.rb +4 -1
  15. data/lib/samagotchi/bridge.rb +8 -3
  16. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  17. data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
  18. data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
  19. data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
  20. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
  21. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/self_map.md +6 -0
  26. data/lib/samagotchi/child_reports.rb +31 -12
  27. data/lib/samagotchi/client_id.rb +52 -0
  28. data/lib/samagotchi/config.rb +6 -0
  29. data/lib/samagotchi/context_absorber.rb +180 -0
  30. data/lib/samagotchi/context_command.rb +473 -0
  31. data/lib/samagotchi/context_fetch.rb +195 -0
  32. data/lib/samagotchi/context_note.rb +11 -5
  33. data/lib/samagotchi/context_poller.rb +100 -0
  34. data/lib/samagotchi/context_providers.rb +159 -0
  35. data/lib/samagotchi/context_sources.rb +462 -0
  36. data/lib/samagotchi/engine.rb +19 -11
  37. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  38. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -9
  39. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  40. data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
  41. data/lib/samagotchi/guardrails/verdict.rb +21 -3
  42. data/lib/samagotchi/log_line.rb +1 -1
  43. data/lib/samagotchi/memory_bundle/builder.rb +22 -1
  44. data/lib/samagotchi/memory_bundle/installer.rb +43 -1
  45. data/lib/samagotchi/memory_bundle/manifest.rb +42 -4
  46. data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
  47. data/lib/samagotchi/plugin/attached_context.rb +100 -0
  48. data/lib/samagotchi/plugin/context.rb +22 -0
  49. data/lib/samagotchi/plugin/sessions.rb +2 -1
  50. data/lib/samagotchi/process_group.rb +160 -0
  51. data/lib/samagotchi/prompt_warmup.rb +20 -1
  52. data/lib/samagotchi/question_desk.rb +2 -4
  53. data/lib/samagotchi/relay_verifier.rb +2 -4
  54. data/lib/samagotchi/reply_wait.rb +9 -4
  55. data/lib/samagotchi/send_command.rb +2 -1
  56. data/lib/samagotchi/session.rb +7 -5
  57. data/lib/samagotchi/session_commands.rb +18 -1
  58. data/lib/samagotchi/session_manager.rb +7 -6
  59. data/lib/samagotchi/session_retention.rb +3 -0
  60. data/lib/samagotchi/steer.rb +36 -19
  61. data/lib/samagotchi/system_prompt.rb +1 -1
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -7
  63. data/lib/samagotchi/terminal_ui/formatting.rb +28 -8
  64. data/lib/samagotchi/terminal_ui.rb +2 -4
  65. data/lib/samagotchi/tool_activity.rb +8 -0
  66. data/lib/samagotchi/tool_declarations.rb +21 -0
  67. data/lib/samagotchi/tools/builtin_calls.rb +1 -2
  68. data/lib/samagotchi/tools/builtins.rb +5 -0
  69. data/lib/samagotchi/tools/context_read.rb +163 -0
  70. data/lib/samagotchi/tools/delegate.rb +2 -2
  71. data/lib/samagotchi/tools/execute.rb +10 -81
  72. data/lib/samagotchi/tools/task_runtime.rb +25 -38
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/web/app.rb +111 -2
  75. data/lib/samagotchi/web/public/app.js +57 -28
  76. data/lib/samagotchi/web/public/composer_size.js +7 -6
  77. data/lib/samagotchi/web/public/context_chips.js +285 -0
  78. data/lib/samagotchi/web/public/data.js +24 -0
  79. data/lib/samagotchi/web/public/index.html +49 -3
  80. data/lib/samagotchi/web/public/notify.js +5 -3
  81. data/lib/samagotchi/web/public/question_card.js +3 -4
  82. data/lib/samagotchi/web/public/shown_texts.js +13 -0
  83. data/lib/samagotchi/web/public/stable_html.js +18 -0
  84. data/lib/samagotchi/web/public/timing.js +7 -3
  85. data/lib/samagotchi/web/public/turn_events.js +16 -3
  86. data/lib/samagotchi/web/public/turn_view.js +5 -2
  87. data/lib/samagotchi/worker.rb +154 -6
  88. metadata +18 -1
@@ -472,6 +472,12 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
472
472
  #composer .actions-col button{white-space:nowrap;font-size:12px;padding:0 12px;height:28px;min-height:28px;line-height:28px}
473
473
  #composer .actions-col .split{order:3;display:flex;gap:4px;margin-left:auto}
474
474
  #composer #actionBtn{min-width:64px;font-weight:600}
475
+ /* In a session the composer is one row: the text field (one line, growing with its text; fitPrompt sets its height)
476
+ and Cancel · Send at its bottom right. The key hint is in the placeholder there, so its row goes. */
477
+ body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
478
+ body.has-session #composer textarea{min-height:0}
479
+ body.has-session #composeHint{display:none}
480
+ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
475
481
  /* The model picker (start page only): reads as a label, "host:model ▾", left of Start; a native select under a drawn caret. */
476
482
  /* A select is as wide as its widest option: a hidden copy of the chosen name (data-value) sizes the box to that name instead, and the select fills it. */
477
483
  #composer .actions-col .model-pick{order:0;position:relative;display:inline-block;max-width:min(50%,380px);height:28px;min-height:28px;padding:0 22px 0 8px;border-radius:8px;overflow:hidden;background:transparent;color:var(--text-muted);font-size:12px;line-height:28px;text-align:left;text-overflow:ellipsis;transition:background .12s ease,color .12s ease}
@@ -651,7 +657,8 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
651
657
  #infoBar .meta .copy-attach{padding:2px 8px;border-radius:6px;font-size:11px;font-weight:600;background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong)}
652
658
  #infoBar .meta .copy-attach:hover{color:var(--text-hover);border-color:var(--border-strong)}
653
659
  #infoBar .meta .status{font-size:11px;padding:2px 6px;border-radius:999px}
654
- #infoBar .meta .dir{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);word-break:break-all}
660
+ /* The whole path on one line: a long one loses its start ("…/project/dir", the end is what tells dirs apart); the title holds all of it. */
661
+ #infoBar .meta .dir{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);max-width:48ch;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;direction:rtl;text-align:left}
655
662
  /* The footer sits on the card's glass: its chips are tinted, not solid, so the glass reads through them. */
656
663
  #infoBar .meta .model{font-size:11px;padding:2px 6px;border-radius:6px;background:color-mix(in srgb,var(--bg-hover) 55%,transparent);color:var(--text-3)}
657
664
  /* The memory chip ("mem 2", when the session used some): phones only, where the "mem: …" line is hidden. */
@@ -663,7 +670,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
663
670
  #infoBar .meta .worker-restart{padding:1px 8px;border-radius:5px;font-size:11px;font-weight:600;background:var(--bg-raised);color:var(--warn-bright);border:1px solid var(--warn-border)}
664
671
  #infoBar .meta .worker-restart:hover:not(:disabled){background:var(--warn-border);color:var(--on-warn)}
665
672
  #infoBar .meta .worker-restart:disabled{opacity:.7;cursor:default}
666
- #infoBar .preview-line{font-size:13px;word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;line-height:1.4;max-height:2.8em}
673
+ #infoBar .preview-line{font-size:13px;word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:1;line-clamp:1;overflow:hidden;line-height:1.4;max-height:1.4em}
667
674
  #infoBar .preview-line .preview, #infoBar .preview-line .mem{word-break:break-word}
668
675
  #infoBar .mem{font-size:12px;color:var(--text-muted)}
669
676
  #infoBar button{padding:4px 12px;border-radius:8px;font-size:12px;font-weight:600}
@@ -673,6 +680,38 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
673
680
  #infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
674
681
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
675
682
  #infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
683
+ /* Attached context (context_chips.js): a row of its own under the meta; not the composer's #chips/.chip. */
684
+ #contextChips{order:5;flex-basis:100%;display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-width:0}
685
+ #contextChips[hidden]{display:none}
686
+ #contextChips.only-add{flex-basis:auto}
687
+ #infoBar .ctx-chip.ctx-add{border-style:dashed;color:var(--text-muted);background:transparent}
688
+ .ctx-add-form{display:flex;flex-direction:column;gap:6px;margin:0}
689
+ .ctx-add-form .ctx-pop-head strong{font-family:inherit}
690
+ .ctx-add-input{width:100%;box-sizing:border-box;padding:6px 8px;border-radius:7px;border:1px solid var(--border-strong);background:var(--bg-inset);color:var(--text);font:13px/1.4 inherit}
691
+ .ctx-add-input:focus{outline:none;border-color:var(--accent-border)}
692
+ #infoBar .ctx-chip{display:inline-flex;align-items:center;gap:5px;padding:2px 9px;border-radius:999px;font:600 11px/1.5 ui-monospace,monospace;background:color-mix(in srgb,var(--bg-hover) 55%,transparent);color:var(--text-3);border:1px solid var(--border-strong);white-space:nowrap}
693
+ #infoBar .ctx-chip:hover,#infoBar .ctx-chip.open{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
694
+ #infoBar .ctx-chip.empty{color:var(--text-faint);border-style:dashed}
695
+ #infoBar .ctx-chip.error{color:var(--danger-text);border-color:color-mix(in srgb,var(--danger) 55%,transparent);background:var(--danger-bg-dim)}
696
+ .ctx-dot{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 2px var(--accent-ring)}
697
+ .ctx-popover{position:fixed;z-index:60;max-height:min(60vh,520px);overflow:auto;padding:12px 14px;border-radius:12px;background:var(--bg-panel);color:var(--text-2);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px;line-height:1.45;display:flex;flex-direction:column;gap:6px}
698
+ .ctx-popover[hidden]{display:none}
699
+ .ctx-pop-head{display:flex;align-items:baseline;gap:8px}
700
+ .ctx-pop-head strong{font-family:ui-monospace,monospace;color:var(--text-bright)}
701
+ .ctx-pop-kind{font-size:11px;color:var(--text-muted)}
702
+ .ctx-pop-close{margin-left:auto;padding:0 6px;border:none;background:transparent;color:var(--text-muted);font-size:16px;line-height:1}
703
+ .ctx-pop-close:hover{color:var(--text)}
704
+ .ctx-pop-why{color:var(--text)}
705
+ .ctx-pop-hint{font-size:12px;color:var(--accent);word-break:break-all}
706
+ .ctx-pop-summary{padding:6px 8px;border-radius:8px;background:var(--bg-inset);border-left:3px solid var(--accent-border)}
707
+ .ctx-pop-meta{font-size:11px;color:var(--text-muted)}
708
+ .ctx-pop-error{font-size:12px;color:var(--danger-text)}
709
+ .ctx-pop-text{margin:0;max-height:240px;overflow:auto;padding:8px;border-radius:8px;background:var(--bg-inset);font:12px/1.45 ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;color:var(--text-2)}
710
+ .ctx-pop-actions{display:flex;gap:8px;justify-content:flex-end}
711
+ .ctx-pop-actions button{padding:3px 10px;border-radius:7px;font-size:12px;font-weight:600;background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong)}
712
+ .ctx-pop-actions button:hover{color:var(--text-hover)}
713
+ .ctx-pop-actions .ctx-pop-detach{color:var(--text-muted);background:transparent}
714
+ .ctx-pop-actions .ctx-pop-detach:hover{color:var(--danger-text);border-color:color-mix(in srgb,var(--danger) 55%,transparent)}
676
715
  /* Phones: the footer is one line; its meta scrolls sideways (all of it stays reachable) and the first-message line, the strip card's title, goes. */
677
716
  @media(max-width:600px){
678
717
  /* The key hint: cut off at this width, and phones have no Shift+Enter. */
@@ -688,7 +727,13 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
688
727
  #infoBar .meta .status{order:-1}
689
728
  /* After the status (same order, later in the DOM): the meta scrolls, and only its start is in sight. */
690
729
  #infoBar .meta .mem-chip{display:inline;order:-1}
691
- #infoBar .meta .dir{word-break:normal}
730
+ /* The chips get a second line of their own (only when there are some), scrolling sideways. */
731
+ #infoBar:has(#contextChips:not([hidden])){flex-wrap:wrap;row-gap:4px}
732
+ #contextChips{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none}
733
+ /* The meta scrolls here: a lone "+ URL" would take its width, so it gets the row too. */
734
+ #contextChips.only-add{flex-basis:100%}
735
+ #contextChips::-webkit-scrollbar{display:none}
736
+ #contextChips>*{flex-shrink:0}
692
737
  #infoBar button{padding:4px 8px}
693
738
  /* Icon-only buttons (the words stay as the accessible name, the title says more): the meta gets the width. */
694
739
  #infoBar>button{font-size:0;line-height:0;padding:5px 7px}
@@ -892,6 +937,7 @@ a{color:var(--accent)}
892
937
  </div>
893
938
  <div id="infoBar">
894
939
  <div class="info" id="infoText"></div>
940
+ <div id="contextChips" aria-label="Attached context" hidden></div>
895
941
  <button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
896
942
  <button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop</button>
897
943
  <button class="ghost" id="infoDeleteBtn" title="Delete session (for good)" style="display:none">delete</button>
@@ -13,9 +13,11 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
13
13
  }
14
14
 
15
15
  // A finished turn shorter than this was watched: no "done". Not a turn
16
- // chi ran for a delegate child's report (last_turn origin delegate_report):
17
- // nobody started it, so nobody watched it.
16
+ // chi ran for a delegate child's report (last_turn origin delegate_report)
17
+ // or an attached context change (context): nobody started it, so nobody
18
+ // watched it.
18
19
  export const DONE_MIN_SECONDS = 10;
20
+ const UNWATCHED_ORIGINS = ["delegate_report", "context"];
19
21
 
20
22
  // The step-limit question (kind "continue": a turn ran out of iterations
21
23
  // and asks whether to go on) has its own reason: "out of steps".
@@ -68,7 +70,7 @@ export function attentionFor(prev, next) {
68
70
  if (next.parent_id) return null;
69
71
  const key = `${next.id}:${turn.ended_at}`;
70
72
  if (turn.outcome === "failed") return { reason: "failed", sessionId: next.id, key };
71
- if (turn.outcome === "completed" && (Number(turn.seconds) >= DONE_MIN_SECONDS || turn.origin === "delegate_report")) {
73
+ if (turn.outcome === "completed" && (Number(turn.seconds) >= DONE_MIN_SECONDS || UNWATCHED_ORIGINS.includes(turn.origin))) {
72
74
  return { reason: "done", sessionId: next.id, key };
73
75
  }
74
76
  return null;
@@ -163,14 +163,13 @@ export function approvalAllowed(pq, answer) {
163
163
  }
164
164
 
165
165
  // What the card says when an answer or a dismissal didn't go through. 503
166
- // (the server found no live bridge) reads as the session not running; any
167
- // other failure — the worker's own (the server maps it to a 502
166
+ // not_live (the server found no live bridge) reads as the session not
167
+ // running; any other failure — the worker's own (the server maps it to a 502
168
168
  // bridge_error) or the connection's — shows what it said, so a worker that
169
169
  // failed the request is not mistaken for one that is gone.
170
170
  export function answerErrorText(error) {
171
- const status = error?.status;
172
171
  const message = String(error?.message ?? error ?? "");
173
- if (status === 503 || /not_live/.test(message)) return "Session is not running — restart it to answer.";
172
+ if (error?.status === 503 || error?.code === "not_live") return "Session is not running — restart it to answer.";
174
173
  return message;
175
174
  }
176
175
 
@@ -0,0 +1,13 @@
1
+ // The texts the running turn already shows (its generations as they close,
2
+ // the bubbles a render drew): turn_completed's answer gets a bubble of its
3
+ // own only when it isn't one of them (it wasn't streamed). A new turn starts
4
+ // over, so an earlier turn's "Done." doesn't hide this one's.
5
+ export function createShownTexts() {
6
+ let seen = new Set();
7
+ return {
8
+ turnStarted() { seen = new Set(); },
9
+ seed(texts) { seen = new Set(texts.filter(Boolean)); },
10
+ saw(text) { if (text) seen.add(text); },
11
+ shows(text) { return seen.has(text); },
12
+ };
13
+ }
@@ -0,0 +1,18 @@
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
+ }
@@ -112,6 +112,10 @@ export function turnRecordAt(timing, index) {
112
112
  return Array.isArray(records) ? records[index] || null : null;
113
113
  }
114
114
 
115
+ // A history item that starts a turn: a prompt, or the note a context wake
116
+ // turn starts with (turn_start).
117
+ const startsTurn = (item) => item?.role === "user" || (item?.role === "note" && item.turn_start === true);
118
+
115
119
  // For each history item, the turn index its timing shows under, or null. A
116
120
  // turn with tool calls holds several assistant messages; only the last one
117
121
  // before the next user message carries the turn's timing. A turn with none
@@ -122,7 +126,7 @@ export function timedTurnIndexes(items) {
122
126
  let turnIndex = -1;
123
127
  let carrier = null;
124
128
  items.forEach((item, i) => {
125
- if (item?.role === "user") {
129
+ if (startsTurn(item)) {
126
130
  if (carrier !== null) result[carrier] = turnIndex;
127
131
  turnIndex += 1;
128
132
  carrier = i;
@@ -150,14 +154,14 @@ export function turnGroups(items, timing) {
150
154
  let turnIndex = -1;
151
155
  let turn = null;
152
156
  items.forEach((item, i) => {
153
- if (item?.role === "note") {
157
+ if (item?.role === "note" && !startsTurn(item)) {
154
158
  groups.push({ kind: "note", i });
155
159
  } else if (item?.role === "steer") {
156
160
  if (turn) turn.steers.push(i);
157
161
  else groups.push({ kind: "steer", i });
158
162
  } else if (item?.role === "user" && item.merged && turn) {
159
163
  turn.merged.push(i);
160
- } else if (item?.role === "user") {
164
+ } else if (startsTurn(item)) {
161
165
  turnIndex += 1;
162
166
  turn = { kind: "turn", turnIndex, turnId: item.turn_id, user: i, texts: [], steers: [], merged: [] };
163
167
  groups.push(turn);
@@ -93,7 +93,13 @@ export function isOwn(clientId, myId) {
93
93
 
94
94
  // As the TUI labels them (Formatting::CLIENT_LABELS); this file's label
95
95
  // helpers and the TUI's agree per spec/shared/labels_matrix.json.
96
- const CLIENT_LABELS = Object.freeze({ tui: "tui", web: "web", system: "reminder", delegate: "delegate", child: "delegate report" });
96
+ // By the whole id or its prefix (up to and with its first ":"); an id
97
+ // chi doesn't know is "automatic", not the user (ClientId.human?).
98
+ const CLIENT_LABELS = Object.freeze({
99
+ "tui:": "tui", "web:": "web", "system:": "reminder", "delegate:": "delegate", "child:": "delegate report",
100
+ "cli:send": "chi send", "cli:answer": "chi answer", plugin: "plugin",
101
+ });
102
+ const AUTOMATIC_LABEL = "automatic";
97
103
 
98
104
  // A delegate child's report comes in as client child:<id8> (ChildReports).
99
105
  const REPORT_CLIENT = "child:";
@@ -101,10 +107,17 @@ export function isReportClient(clientId) {
101
107
  return String(clientId || "").startsWith(REPORT_CLIENT);
102
108
  }
103
109
 
104
- // The label on another client's prompt, by its client_id prefix.
110
+ // A turn chi ran because an attached context source changed: context:<name>.
111
+ const CONTEXT_CLIENT = "context:";
112
+
113
+ // The label on another client's prompt, by its client_id prefix; a context
114
+ // change's turn as "context <name> changed".
105
115
  export function clientLabel(clientId) {
106
116
  if (!clientId) return null;
107
- return CLIENT_LABELS[String(clientId).split(":", 1)[0]] || "user";
117
+ const id = String(clientId);
118
+ if (id.startsWith(CONTEXT_CLIENT) && id.length > CONTEXT_CLIENT.length) return `context ${id.slice(CONTEXT_CLIENT.length)} changed`;
119
+ const colon = id.indexOf(":");
120
+ return CLIENT_LABELS[id] || (colon >= 0 && CLIENT_LABELS[id.slice(0, colon + 1)]) || AUTOMATIC_LABEL;
108
121
  }
109
122
 
110
123
  // The note for reminders a turn got (a reminder turn has no prompt).
@@ -390,7 +390,7 @@ export function createTurnView({
390
390
  }
391
391
  writeClosedLabel(entry, gen);
392
392
  if (!entry.toggled) entry.el.open = false;
393
- // Every generation's text goes into seenContent (the turn output dedupe).
393
+ // Every generation's text goes into the shown texts (the turn output dedupe).
394
394
  const k = normalize(gen.text);
395
395
  if (k) sawText(k);
396
396
  }
@@ -826,7 +826,10 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
826
826
  if (group.kind === "steer") return steerHtml(group.i, true);
827
827
  if (group.kind === "answer") return answerHtml(items[group.answer], "");
828
828
  const prompt = items[group.user];
829
- const parts = [`<div class="bubble user"${copySourceAttr(prompt, false)}>${originLabelHtml(prompt)}<div class="user-message">${messageBodyHtml(prompt).body}${thumbs(prompt.images)}</div></div>`];
829
+ // A context wake turn starts with its note.
830
+ const parts = [prompt.role === "note"
831
+ ? `<div class="bubble note">${noteHtml(prompt)}</div>`
832
+ : `<div class="bubble user"${copySourceAttr(prompt, false)}>${originLabelHtml(prompt)}<div class="user-message">${messageBodyHtml(prompt).body}${thumbs(prompt.images)}</div></div>`];
830
833
  const [only] = group.steps;
831
834
  const ended = group.answer !== null || group.emptyAnswer != null;
832
835
  const textless = only && (only.i === null || !String(items[only.i].content ?? ""));
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "securerandom"
5
+ require "time"
4
6
 
7
+ require_relative "client_id"
8
+ require_relative "config"
5
9
  require_relative "events"
6
10
  require_relative "session"
7
11
  require_relative "session_inbox"
@@ -18,6 +22,8 @@ require_relative "iteration_limit"
18
22
  require_relative "session_commands"
19
23
  require_relative "model_profile"
20
24
  require_relative "child_reports"
25
+ require_relative "context_absorber"
26
+ require_relative "context_poller"
21
27
  require_relative "tools/task_runtime"
22
28
 
23
29
  module Samagotchi
@@ -51,6 +57,9 @@ module Samagotchi
51
57
  # gets it over the Bridge a moment after it starts, and that message's
52
58
  # turn goes first (the reports join it).
53
59
  WAKE_START_GRACE = 2.0
60
+ # One context wake per source in this long (D5): later changes inside
61
+ # it arrive as notes.
62
+ CONTEXT_WAKE_WINDOW = 600
54
63
  # How much of a command's output goes into its :command_ran.
55
64
  COMMAND_OUTPUT_LIMIT = 4096
56
65
  BUSY_OUTPUT = "busy: wait for the turn to end"
@@ -168,6 +177,9 @@ module Samagotchi
168
177
  })
169
178
  # This session's own delegate children's news (rings in children/).
170
179
  @child_reports = ChildReports.new(session_id: @session_id, state_dir: @state_dir)
180
+ # Its attached context's notes (ContextSources).
181
+ @context_absorber = ContextAbsorber.new(session_id: @session_id, state_dir: @state_dir,
182
+ project_root: @session.project_root)
171
183
  # /model's default is the config's, as in the REPL; the Engine started
172
184
  # on the session's model.
173
185
  @default_model = ModelProfile.required_model_name(nil)
@@ -211,6 +223,11 @@ module Samagotchi
211
223
  # the background, shown by the UIs; a turn waits only for the ones
212
224
  # that bring tools.
213
225
  @engine.start_init_tasks!
226
+ # Attached context's commands, in the background; a new text wakes
227
+ # the loop to absorb it. Not activity: the idle exit stops it.
228
+ @context_poller = ContextPoller.new(session_id: @session_id, state_dir: @state_dir,
229
+ project_root: @session.project_root, cwd: @session.working_directory,
230
+ on_change: -> { @waker.wake }).start
214
231
  loop do
215
232
  # Check if the session was externally marked as stopped. Not
216
233
  # stopped_on_disk?, which reads a vanished file as "not stopped":
@@ -223,7 +240,10 @@ module Samagotchi
223
240
  next if run_queued_commands
224
241
 
225
242
  # Between turns, so the next turn (the first one too) sees them.
243
+ # A change that asks to wake may start a turn here (C4), but not
244
+ # ahead of an exit or restart a client asked for: it is a note then.
226
245
  absorb_notes
246
+ next if absorb_context(wake: nothing_queued? && !@exit_requested)
227
247
 
228
248
  if (prompt = take_initial_prompt)
229
249
  run_initial_prompt(prompt) unless initial_command(prompt)
@@ -263,8 +283,14 @@ module Samagotchi
263
283
  # step-limit question stays in the file (a save here would write
264
284
  # over a stop's status): with no live worker the lists don't read
265
285
  # it as waiting, and the next worker drops it (drop_dead_question).
286
+ # The context poller first: a command it runs goes (its process
287
+ # group) before the Engine does.
288
+ @context_poller&.stop
266
289
  @engine&.shutdown
267
290
  @bridge&.stop
291
+ # The process exits next: a parent wake it rang for (a crash's
292
+ # ring comes just above) must spawn the parent's worker first.
293
+ ChildRing.await_wakes
268
294
  end
269
295
  end
270
296
 
@@ -372,6 +398,123 @@ module Samagotchi
372
398
  claimed.each { |file| FileUtils.rm_f(file) }
373
399
  end
374
400
 
401
+ # The attached context's notes (ContextAbsorber), between turns. Not
402
+ # into a session with no turn yet (+before_turn+: one is about to
403
+ # run): auto-attached context alone doesn't make a session worth
404
+ # keeping. Saved, then the subscriptions written (a crash between the
405
+ # two re-delivers; the note ids dedupe).
406
+ # +wake+: the session is idle with nothing queued, so an update whose
407
+ # source asked to wake may start a turn (#context_wake_for): its note
408
+ # goes in as the turn's first message.
409
+ # @return [Boolean] whether a wake turn ran
410
+ def absorb_context(before_turn: false, wake: false)
411
+ return false unless before_turn || conversation_started?
412
+ return false if stopped_on_disk?
413
+
414
+ batch = @context_absorber.pending
415
+ return false unless batch
416
+
417
+ waking = wake ? context_wake_for(batch) : nil
418
+ turn_id = SecureRandom.uuid if waking
419
+ batch.deliveries.each do |delivery|
420
+ next unless delivery.note
421
+
422
+ note = delivery == waking ? delivery.wake_note.merge(turn_start: true, turn_id: turn_id) : delivery.note
423
+ @engine.add_context_note(@session, note)
424
+ end
425
+ # Saved even when every note was there already: after a failed save
426
+ # they are in memory only.
427
+ return false if batch.notes.any? && !save_or_log(:context) { @session.save(state_dir: @state_dir) }
428
+
429
+ @context_absorber.commit(waking ? woken(batch, waking) : batch)
430
+ return false unless waking
431
+
432
+ run_context_wake_turn(waking, turn_id)
433
+ true
434
+ rescue SystemCallError, IOError => e
435
+ Log.exception(:worker, "context_failed", e)
436
+ false
437
+ end
438
+
439
+ # No input file or queued command waits: the loop would go idle.
440
+ def nothing_queued? = @command_queue.empty? && SessionInbox.find_new_input_files(@session_dir).empty?
441
+
442
+ # The update in +batch+ that may start a wake turn now: its source
443
+ # asked (wake: true), context.wake is on, the shared wake step's
444
+ # conditions hold (no continue offer, wakes not paused by a failed one,
445
+ # under session.max_wakes in a row with no human input, past the start
446
+ # grace) and the source hasn't woken the session in the last
447
+ # CONTEXT_WAKE_WINDOW. The others stay plain notes. Within the start
448
+ # grace a change is a note: it came while the session was away.
449
+ def context_wake_for(batch, now: Time.now)
450
+ candidates = batch.deliveries.select(&:wake_note)
451
+ return nil if candidates.empty? || !context_wakes_on?
452
+ return nil if @wakes_paused || @turn_flow.awaiting_continue? || wake_grace_left.positive?
453
+
454
+ if @wakes_in_a_row >= max_wakes
455
+ Log.info(:worker, "context_wake_held", reason: "max_wakes", names: candidates.map(&:name).join(","))
456
+ return nil
457
+ end
458
+ candidates.find { |delivery| !woke_lately?(delivery.subscription, now) }
459
+ end
460
+
461
+ def context_wakes_on? = Config.get("context.wake") != false
462
+
463
+ def woke_lately?(subscription, now)
464
+ at = subscription&.wakes_at && Time.iso8601(subscription.wakes_at)
465
+ at ? now - at < CONTEXT_WAKE_WINDOW : false
466
+ rescue ArgumentError
467
+ false
468
+ end
469
+
470
+ # +batch+ with +waking+'s subscription recording the wake (the 10-minute window).
471
+ def woken(batch, waking)
472
+ stamped = waking.with(subscription: waking.subscription.with(wakes_at: Time.now.iso8601))
473
+ batch.with(deliveries: batch.deliveries.map { |delivery| delivery == waking ? stamped : delivery })
474
+ end
475
+
476
+ # A turn nobody typed for a changed source (a context wake): a continue
477
+ # turn from context:<name> whose first message is the wake note already
478
+ # in the conversation (named by +turn_id+), on the same rules as a
479
+ # delegate report's wake turn: it counts toward session.max_wakes, and
480
+ # a failure goes back to before the turn (the note stays, as a plain
481
+ # one: #unmark_wake_note) and pauses wakes until a human's input.
482
+ # +waking+: the ContextAbsorber::Delivery that woke it.
483
+ def run_context_wake_turn(waking, turn_id)
484
+ name = waking.name
485
+ @wakes_in_a_row += 1
486
+ Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes_in_a_row)
487
+ @turn_flow.before_prompt_turn
488
+ run_engine_turn(nil, continue: true, origin: { client_id: "#{ClientId::CONTEXT_PREFIX}#{name}" },
489
+ id: turn_id, max_iterations: IterationLimit.for) do |result, error|
490
+ if error
491
+ note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message)
492
+ @turn_flow.prompt_turn_failed(note: note)
493
+ unmark_wake_note(turn_id, waking.note)
494
+ @wakes_paused = true
495
+ else
496
+ @continue_offer.after_turn(result)
497
+ end
498
+ end
499
+ end
500
+
501
+ # A failed wake turn kept nothing but its note: the update goes back to
502
+ # a plain note (no turn start, the background wording), so a reload
503
+ # draws no empty turn for it.
504
+ def unmark_wake_note(turn_id, note)
505
+ messages = @engine.messages_checkpoint
506
+ index = messages.index { |m| m[:turn_start] && m[:turn_id] == turn_id }
507
+ return unless index
508
+
509
+ messages[index] = ContextNote.message(**note)
510
+ @engine.rollback_to(messages)
511
+ end
512
+
513
+ # Whether the session has had a turn: a user or assistant message.
514
+ def conversation_started?
515
+ Array(@session.messages).any? { |m| %w[user assistant model].include?((m[:role] || m["role"]).to_s) }
516
+ end
517
+
375
518
  # The first prompt (spawn_session's): a delegate child's task, which is
376
519
  # its parent's although no client sent it.
377
520
  def run_initial_prompt(prompt)
@@ -400,6 +543,8 @@ module Samagotchi
400
543
  # its continue turn
401
544
  # @param images [Array<Hash>] the prompt's image refs ({file:, name:})
402
545
  def run_prompt(prompt, origin, no_interrupt: false, images: [])
546
+ # A first turn sees the context attached before it.
547
+ absorb_context(before_turn: true)
403
548
  user_input(origin&.dig(:client_id))
404
549
  @continue_offer.drop(origin)
405
550
  @turn_flow.before_prompt_turn
@@ -425,11 +570,11 @@ module Samagotchi
425
570
  @engine.clear_due_reminder_names!
426
571
  return false unless @engine.reminders_due?
427
572
 
428
- @continue_offer.drop({ client_id: SessionManager::REMINDER_CLIENT_ID })
573
+ @continue_offer.drop({ client_id: ClientId::REMINDER })
429
574
  # A failure has no prompt to hand back (the Engine announced
430
575
  # :turn_failed). The offer went before the turn
431
576
  # (ContinueOffer#drop); either way the rollback window closes.
432
- run_engine_turn(nil, continue: true, origin: { client_id: SessionManager::REMINDER_CLIENT_ID },
577
+ run_engine_turn(nil, continue: true, origin: { client_id: ClientId::REMINDER },
433
578
  max_iterations: IterationLimit.for) { @turn_flow.after_reminder_turn }
434
579
  true
435
580
  end
@@ -633,8 +778,11 @@ module Samagotchi
633
778
  rescue StandardError => e
634
779
  error = e
635
780
  # Still running: no turn of the Engine's ended (it never began).
781
+ # It ends here, failed, as an Engine turn would record it: a wait
782
+ # (ReplyWait) tells a turn's end by last_turn.
636
783
  if @session.status == Session::STATUS_RUNNING
637
784
  @session.status = Session::STATUS_IDLE
785
+ @engine.record_last_turn(@session, "failed", 0, turn_args[:origin], { type: :turn_failed })
638
786
  Log.warn(:worker, "turn_not_begun", error: e.class.name, msg: e.message)
639
787
  end
640
788
  ensure
@@ -674,12 +822,12 @@ module Samagotchi
674
822
  # Whether the turn starting now is a delegate child's parent's: its
675
823
  # task (the initial prompt), a follow-up, a continue the parent
676
824
  # answered, a reminder, anything but a human typing into the child
677
- # (ArchiveStore.user_input?), and any turn after a question the parent
825
+ # (ClientId.human?), and any turn after a question the parent
678
826
  # was rung about.
679
827
  def reports_to_parent?(origin)
680
828
  return false unless @session.delegate?
681
829
 
682
- @initial_turn || @owes_parent || !ArchiveStore.user_input?(origin&.dig(:client_id))
830
+ @initial_turn || @owes_parent || !ClientId.human?(origin&.dig(:client_id))
683
831
  end
684
832
 
685
833
  # The Engine published a question (after the question desk saved it).
@@ -897,11 +1045,11 @@ module Samagotchi
897
1045
  Log.info(:worker, "idle_exit", idle_s: @idle_exit.idle_seconds.round)
898
1046
  end
899
1047
 
900
- # A human's input (ArchiveStore.user_input?) un-archives the session;
1048
+ # A human's input (ClientId.human?) un-archives the session;
901
1049
  # a delegate's, a plugin's or a reminder's doesn't.
902
1050
  # It also resets the delegate-report wakes (budget and pause).
903
1051
  def user_input(client_id)
904
- return unless ArchiveStore.user_input?(client_id)
1052
+ return unless ClientId.human?(client_id)
905
1053
 
906
1054
  ArchiveStore.user_input(@session_id, state_dir: @state_dir)
907
1055
  @wakes_in_a_row = 0
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.31.0
4
+ version: 0.33.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -125,6 +125,7 @@ files:
125
125
  - docs/architecture.md
126
126
  - docs/cli.md
127
127
  - docs/configuration.md
128
+ - docs/context.md
128
129
  - docs/desktop.md
129
130
  - docs/guardrails.md
130
131
  - docs/hooks.md
@@ -170,6 +171,9 @@ files:
170
171
  - lib/samagotchi/bundles/check-in/plugin.rb
171
172
  - lib/samagotchi/bundles/core/manifest.yml
172
173
  - lib/samagotchi/bundles/dev/manifest.yml
174
+ - lib/samagotchi/bundles/github-pr/manifest.yml
175
+ - lib/samagotchi/bundles/github-pr/plugin.rb
176
+ - lib/samagotchi/bundles/github-pr/scripts/pr_context.rb
173
177
  - lib/samagotchi/bundles/guardrails/guardrails.md
174
178
  - lib/samagotchi/bundles/guardrails/guardrails/rules.yml
175
179
  - lib/samagotchi/bundles/guardrails/guardrails/small-models.yml
@@ -199,12 +203,19 @@ files:
199
203
  - lib/samagotchi/cli/flags.rb
200
204
  - lib/samagotchi/cli/parent_wait.rb
201
205
  - lib/samagotchi/client.rb
206
+ - lib/samagotchi/client_id.rb
202
207
  - lib/samagotchi/command_steps.rb
203
208
  - lib/samagotchi/commands/registry.rb
204
209
  - lib/samagotchi/config.rb
205
210
  - lib/samagotchi/config_text_edit.rb
211
+ - lib/samagotchi/context_absorber.rb
212
+ - lib/samagotchi/context_command.rb
213
+ - lib/samagotchi/context_fetch.rb
206
214
  - lib/samagotchi/context_note.rb
215
+ - lib/samagotchi/context_poller.rb
216
+ - lib/samagotchi/context_providers.rb
207
217
  - lib/samagotchi/context_quote.rb
218
+ - lib/samagotchi/context_sources.rb
208
219
  - lib/samagotchi/context_status.rb
209
220
  - lib/samagotchi/context_usage.rb
210
221
  - lib/samagotchi/context_window.rb
@@ -315,6 +326,7 @@ files:
315
326
  - lib/samagotchi/paths.rb
316
327
  - lib/samagotchi/pending_input_queue.rb
317
328
  - lib/samagotchi/plugin/api.rb
329
+ - lib/samagotchi/plugin/attached_context.rb
318
330
  - lib/samagotchi/plugin/context.rb
319
331
  - lib/samagotchi/plugin/loader.rb
320
332
  - lib/samagotchi/plugin/service.rb
@@ -323,6 +335,7 @@ files:
323
335
  - lib/samagotchi/plugin/tool_result.rb
324
336
  - lib/samagotchi/plugin_session_state.rb
325
337
  - lib/samagotchi/plugin_tasks.rb
338
+ - lib/samagotchi/process_group.rb
326
339
  - lib/samagotchi/project_scope.rb
327
340
  - lib/samagotchi/prompt.rb
328
341
  - lib/samagotchi/prompt_history.rb
@@ -394,6 +407,7 @@ files:
394
407
  - lib/samagotchi/tools/builtin_calls.rb
395
408
  - lib/samagotchi/tools/builtins.rb
396
409
  - lib/samagotchi/tools/cancel_reminder.rb
410
+ - lib/samagotchi/tools/context_read.rb
397
411
  - lib/samagotchi/tools/delegate.rb
398
412
  - lib/samagotchi/tools/delegate_cursor.rb
399
413
  - lib/samagotchi/tools/delegate_relay.rb
@@ -448,6 +462,7 @@ files:
448
462
  - lib/samagotchi/web/public/command_list.js
449
463
  - lib/samagotchi/web/public/command_view.js
450
464
  - lib/samagotchi/web/public/composer_size.js
465
+ - lib/samagotchi/web/public/context_chips.js
451
466
  - lib/samagotchi/web/public/copy.js
452
467
  - lib/samagotchi/web/public/ctx.js
453
468
  - lib/samagotchi/web/public/data.js
@@ -470,6 +485,8 @@ files:
470
485
  - lib/samagotchi/web/public/sentences.js
471
486
  - lib/samagotchi/web/public/session_state.js
472
487
  - lib/samagotchi/web/public/sessions_list.js
488
+ - lib/samagotchi/web/public/shown_texts.js
489
+ - lib/samagotchi/web/public/stable_html.js
473
490
  - lib/samagotchi/web/public/stage_model.js
474
491
  - lib/samagotchi/web/public/stage_view.js
475
492
  - lib/samagotchi/web/public/strip.js