samagotchi 0.65.0 → 0.67.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +107 -1
- data/README.md +2 -1
- data/bin/chi +51 -2
- data/docs/architecture.md +50 -28
- data/docs/cli.md +14 -2
- data/docs/configuration.md +101 -16
- data/docs/context.md +39 -2
- data/docs/desktop.md +90 -22
- data/docs/guardrails.md +8 -5
- data/docs/hooks.md +2 -2
- data/docs/internals/anthropic.md +156 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -0
- data/docs/internals/engine.md +75 -30
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +36 -13
- data/docs/memory.md +1 -1
- data/docs/plugins.md +289 -25
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +16 -6
- data/docs/sub-agent.md +6 -1
- data/docs/testing.md +17 -3
- data/lib/samagotchi/bootstrap/probe.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +13 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +6 -3
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +40 -302
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +22 -0
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +189 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +136 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +76 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
- data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
- data/lib/samagotchi/bundles/system/delegated.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +25 -8
- data/lib/samagotchi/config.rb +23 -8
- data/lib/samagotchi/context_absorber.rb +66 -13
- data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
- data/lib/samagotchi/desktop/macos/App.swift +3 -3
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
- data/lib/samagotchi/desktop/macos/Images.swift +2 -2
- data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
- data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
- data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
- data/lib/samagotchi/desktop/macos.rb +84 -13
- data/lib/samagotchi/desktop_command.rb +14 -3
- data/lib/samagotchi/engine.rb +120 -107
- data/lib/samagotchi/engine_peers.rb +3 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +1 -1
- data/lib/samagotchi/guardrails/approvals.rb +1 -1
- data/lib/samagotchi/guardrails/rules.rb +1 -1
- data/lib/samagotchi/guardrails/targets.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +151 -0
- data/lib/samagotchi/hooks/registry.rb +2 -2
- data/lib/samagotchi/host_keys.rb +147 -0
- data/lib/samagotchi/kernel_loop.rb +9 -1
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
- data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
- data/lib/samagotchi/llm/api_key.rb +23 -7
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/openai_chat.rb +9 -92
- data/lib/samagotchi/llm/refusal_memo.rb +29 -0
- data/lib/samagotchi/llm_context_override.rb +1 -0
- data/lib/samagotchi/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +138 -156
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/loader.rb +5 -2
- data/lib/samagotchi/plugin/peer.rb +394 -0
- data/lib/samagotchi/plugin/tool_spec.rb +166 -0
- data/lib/samagotchi/plugin_tasks.rb +2 -2
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +41 -15
- data/lib/samagotchi/session.rb +18 -1
- data/lib/samagotchi/session_commands.rb +16 -1
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_setup.rb +14 -7
- data/lib/samagotchi/system_prompt.rb +16 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
- data/lib/samagotchi/terminal_ui.rb +18 -2
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +13 -1
- data/lib/samagotchi/toolkit.rb +246 -0
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/toolkit_command.rb +117 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +43 -6
- data/lib/samagotchi/tools/task_create.rb +6 -6
- data/lib/samagotchi/tools/task_get.rb +13 -13
- data/lib/samagotchi/tools/task_list.rb +6 -6
- data/lib/samagotchi/tools/task_record.rb +52 -0
- data/lib/samagotchi/tools/task_runtime.rb +55 -91
- data/lib/samagotchi/tools/task_stop.rb +6 -6
- data/lib/samagotchi/tools/task_wait.rb +12 -13
- data/lib/samagotchi/tools/tool_index.rb +454 -0
- data/lib/samagotchi/update_command.rb +9 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -9
- data/lib/samagotchi/web/capabilities.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/annotate_actions.js +109 -0
- data/lib/samagotchi/web/public/annotate_bar.js +84 -2
- data/lib/samagotchi/web/public/app.js +52 -43
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/commands.js +6 -4
- data/lib/samagotchi/web/public/composer_size.js +2 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/ctx.js +1 -1
- data/lib/samagotchi/web/public/delivery_line.js +4 -4
- data/lib/samagotchi/web/public/families_view.js +1 -1
- data/lib/samagotchi/web/public/format.js +11 -7
- data/lib/samagotchi/web/public/images.js +25 -2
- data/lib/samagotchi/web/public/index.html +9 -0
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
- data/lib/samagotchi/web/public/notify.js +6 -4
- data/lib/samagotchi/web/public/question_card.js +1 -1
- data/lib/samagotchi/web/public/ref_scan.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +17 -0
- data/lib/samagotchi/web/public/session_title.js +2 -3
- data/lib/samagotchi/web/public/sessions_list.js +0 -8
- data/lib/samagotchi/web/public/stage_model.js +2 -2
- data/lib/samagotchi/web/public/stream_handlers.js +13 -0
- data/lib/samagotchi/web/public/strip.js +3 -3
- data/lib/samagotchi/web/public/task_stop.js +3 -2
- data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_model.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/web/server.rb +0 -4
- data/lib/samagotchi/worker.rb +13 -8
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- metadata +23 -1
|
@@ -67,7 +67,30 @@ export function turnRefs(chips) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
// Chips again for a prompt the worker handed back (its images are stored
|
|
70
|
-
// already, so they need no upload).
|
|
70
|
+
// already, so they need no upload: to that session).
|
|
71
71
|
export function restoredChips(sessionId, images) {
|
|
72
|
-
return (images || []).filter((i) => i && i.file)
|
|
72
|
+
return (images || []).filter((i) => i && i.file)
|
|
73
|
+
.map((ref) => ({ name: ref.name || "image", ref, session: sessionId, src: imageUrl(sessionId, ref) }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A restored chip has no file of its own: its image, read back from where
|
|
77
|
+
// its session stores it.
|
|
78
|
+
async function fetchChipFile(src, name) {
|
|
79
|
+
const res = await fetch(src);
|
|
80
|
+
if (!res.ok) throw new Error(`Can't read ${name} again (${res.status})`);
|
|
81
|
+
const blob = await res.blob();
|
|
82
|
+
return new File([blob], name, { type: blob.type });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Uploads the chips a turn to +sessionId+ needs: a ref is into one session's
|
|
86
|
+
// image store (chip.session), so a chip uploaded to another one (a failed
|
|
87
|
+
// send there, then a switch) goes up again, to this one. Sets each chip's
|
|
88
|
+
// ref and session in place: a failed send keeps them for another try.
|
|
89
|
+
export async function uploadChips(chips, sessionId, { upload, fetchFile = fetchChipFile }) {
|
|
90
|
+
for (const chip of chips || []) {
|
|
91
|
+
if (chip.ref && chip.session === sessionId) continue;
|
|
92
|
+
const file = chip.file || await fetchFile(chip.src, chip.name);
|
|
93
|
+
chip.ref = await upload(sessionId, file, chip.name);
|
|
94
|
+
chip.session = sessionId;
|
|
95
|
+
}
|
|
73
96
|
}
|
|
@@ -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
|
|
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),
|
|
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.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
const STRATEGIES = [
|
|
18
18
|
{ value: "none", label: "none" },
|
|
19
19
|
{ value: "stale", label: "stale" },
|
|
20
20
|
{ value: "stale,forget", label: "stale + forget (experimental)" },
|
|
@@ -144,7 +144,7 @@ export function choiceWords(choice) {
|
|
|
144
144
|
|
|
145
145
|
// The strategy list, with the session's own value when it isn't one of them
|
|
146
146
|
// (forget alone, a newer chi's layer).
|
|
147
|
-
|
|
147
|
+
function strategyOptions(chosen) {
|
|
148
148
|
if (chosen === DEFAULT || STRATEGIES.some((s) => s.value === chosen)) return STRATEGIES;
|
|
149
149
|
return [...STRATEGIES, { value: chosen, label: chosen }];
|
|
150
150
|
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// summaries (GET /api/events). The page shows an OS notification and a
|
|
3
3
|
// title badge for what it returns, while the tab is not in front.
|
|
4
4
|
|
|
5
|
+
import { clip } from "./format.js";
|
|
6
|
+
|
|
5
7
|
// The bell's state. OS notifications need a secure context: on chi web
|
|
6
8
|
// over http at a LAN address the API is missing or always denied, so the
|
|
7
9
|
// bell is hidden there (the title badge still counts).
|
|
@@ -16,7 +18,7 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
|
|
|
16
18
|
// chi ran for a delegate child's report (last_turn origin delegate_report)
|
|
17
19
|
// or an attached context change (context): nobody started it, so nobody
|
|
18
20
|
// watched it.
|
|
19
|
-
|
|
21
|
+
const DONE_MIN_SECONDS = 10;
|
|
20
22
|
const UNWATCHED_ORIGINS = ["delegate_report", "context"];
|
|
21
23
|
|
|
22
24
|
// The step-limit question (kind "continue": a turn ran out of iterations
|
|
@@ -90,7 +92,7 @@ const cardKey = (sessionId, cardId) => `${sessionId}:card:${cardId}`;
|
|
|
90
92
|
// @param prev the summary held before (null for a session not seen yet)
|
|
91
93
|
// @param next the new summary (null when the session is gone)
|
|
92
94
|
// @return [String] keys, maybe none
|
|
93
|
-
|
|
95
|
+
function closedKeys(prev, next) {
|
|
94
96
|
const keys = [];
|
|
95
97
|
const q = prev?.pending_question;
|
|
96
98
|
const nq = next?.pending_question;
|
|
@@ -147,7 +149,7 @@ export function trackAttention(state, type, data = {}) {
|
|
|
147
149
|
// No question or answer text (it would show on a lock screen).
|
|
148
150
|
export function attentionText(session, attention) {
|
|
149
151
|
const name = String(session?.title || session?.first_preview || session?.last_prompt || "chi session").trim();
|
|
150
|
-
const title = name
|
|
152
|
+
const title = clip(name, 61);
|
|
151
153
|
const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
|
|
152
154
|
const body = {
|
|
153
155
|
question: "needs an answer",
|
|
@@ -171,7 +173,7 @@ export function attentionText(session, attention) {
|
|
|
171
173
|
// lowest claim (a tab id) shows it (4.11). Without BroadcastChannel every
|
|
172
174
|
// tab goes on alone, at once.
|
|
173
175
|
export const NOTIFY_CHANNEL = "chi-notify";
|
|
174
|
-
|
|
176
|
+
const NOTIFY_HOLD_MS = 300;
|
|
175
177
|
// How long a key seen in front is remembered (its frame may come late).
|
|
176
178
|
const SEEN_TTL_MS = 10 * 60 * 1000;
|
|
177
179
|
|
|
@@ -70,7 +70,7 @@ export function approvalView(pq) {
|
|
|
70
70
|
const reason = a.reason ? String(a.reason) : "(no reason given)";
|
|
71
71
|
return {
|
|
72
72
|
// A plugin tool's label ("chrome: screenshot"), as its row shows it;
|
|
73
|
-
// else the tool the call acts as (an MCP tool behind
|
|
73
|
+
// else the tool the call acts as (an MCP tool behind call_tool).
|
|
74
74
|
tool: a.label || a.acts_as || a.tool || "",
|
|
75
75
|
// A tool without a command or path (an MCP tool): its compact args.
|
|
76
76
|
what: command || paths.join("\n") || a.args || "",
|
|
@@ -30,7 +30,7 @@ const SKIP = "pre, a, [data-ref-kind]";
|
|
|
30
30
|
// The ref a match names: { path, line, endLine } or null. A path has a `/`
|
|
31
31
|
// or an extension (`10:30` is a time, `host:8080` a port); line 0 is never
|
|
32
32
|
// a ref; an end line not after the line is dropped.
|
|
33
|
-
|
|
33
|
+
function refOf(groups) {
|
|
34
34
|
const path = groups?.path || "";
|
|
35
35
|
if (!path.includes("/") && !/\.[A-Za-z0-9]+$/.test(path)) return null;
|
|
36
36
|
if (groups.from === undefined) return { path, line: null, endLine: null };
|
|
@@ -48,7 +48,17 @@ 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(),
|
|
53
|
+
// The event id (or, from an older worker, the event_seq) the session was
|
|
54
|
+
// rendered at (null: no live worker then), where a stream opened later
|
|
55
|
+
// has to start; set by the page's resync, not by the payload.
|
|
56
|
+
streamSeq: null,
|
|
57
|
+
// The stream was opened from 0 (a wake with no cursor to go on) and no
|
|
58
|
+
// new turn has started on it yet (app.js followWokenWorker).
|
|
59
|
+
streamFromGuess: false,
|
|
60
|
+
// When the info bar's "copy chi --attach" copied (its "copied ✓").
|
|
61
|
+
attachCopiedAt: 0,
|
|
52
62
|
};
|
|
53
63
|
}
|
|
54
64
|
|
|
@@ -64,6 +74,11 @@ export function sessionStateFrom(data) {
|
|
|
64
74
|
served: s.served_model ? [s.served_model, s.served_model_for, s.served_expected_by || null] : null,
|
|
65
75
|
dir: s.working_directory,
|
|
66
76
|
owner: s.owner || null,
|
|
77
|
+
// Its live worker's chi and features (null and [] without one): a
|
|
78
|
+
// switch shows its stale-worker badge, task stop and queue at once,
|
|
79
|
+
// not at the session's next hub event (followSelectedSession).
|
|
80
|
+
workerVersion: s.worker_version || null,
|
|
81
|
+
workerFeatures: Array.isArray(s.worker_features) ? s.worker_features : [],
|
|
67
82
|
timing: normalizeTiming(data.timing),
|
|
68
83
|
};
|
|
69
84
|
// The saved fill, so a reloaded or stopped session shows it before its
|
|
@@ -83,6 +98,8 @@ export function sessionStateFrom(data) {
|
|
|
83
98
|
state.thinking = s.thinking || null;
|
|
84
99
|
// null on a native host (it ignores the level) and from an older worker.
|
|
85
100
|
state.reasoningHistory = s.reasoning_history || null;
|
|
101
|
+
// The tools chip's (null on full).
|
|
102
|
+
state.toolkit = s.toolkit || null;
|
|
86
103
|
state.parentId = s.parent_id || null;
|
|
87
104
|
state.continues = s.continues || null;
|
|
88
105
|
state.commands = Array.isArray(data.commands) ? data.commands : [];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalize } from "./format.js";
|
|
1
|
+
import { clip, normalize } from "./format.js";
|
|
2
2
|
|
|
3
3
|
// Where the open session's title shows: once on screen. Its strip card says
|
|
4
4
|
// it when the strip is shown and has an `.active` card for it; otherwise
|
|
@@ -21,6 +21,5 @@ export function topBarTitle({ title, cardShown, stripHidden, allView }) {
|
|
|
21
21
|
export function tabTitle({ title, base }) {
|
|
22
22
|
const text = normalize(title);
|
|
23
23
|
if (!text) return base;
|
|
24
|
-
|
|
25
|
-
return `${cut} · ${base}`;
|
|
24
|
+
return `${clip(text, TAB_TITLE_CHARS + 1)} · ${base}`;
|
|
26
25
|
}
|
|
@@ -188,14 +188,6 @@ export function familySummary(f) {
|
|
|
188
188
|
return delegatesSummary(f.members.map((m) => m.session));
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
// The chip's words: "▸ 3 delegates · 1 live · 1 waiting".
|
|
192
|
-
export function familyChipText(sum) {
|
|
193
|
-
if (!sum) return "";
|
|
194
|
-
const waiting = sum.counts?.waiting || 0;
|
|
195
|
-
return [`\u25B8 ${sum.total} ${sum.total === 1 ? "delegate" : "delegates"}`,
|
|
196
|
-
sum.live ? `${sum.live} live` : "", waiting ? `${waiting} waiting` : ""].filter(Boolean).join(" \u00b7 ");
|
|
197
|
-
}
|
|
198
|
-
|
|
199
191
|
// Whether a family's list is open: the user's toggle (+toggles+, head id →
|
|
200
192
|
// bool) wins; else open when a member waits on the user (the head's own
|
|
201
193
|
// wait is on its card already) or a search matched a member.
|
|
@@ -15,9 +15,9 @@ const FILE_TOOLS = new Set(["read", "edit", "write"]);
|
|
|
15
15
|
const TASK_TOOLS = new Set(["task_wait", "task_get", "task_stop"]);
|
|
16
16
|
const TRAIL = 3;
|
|
17
17
|
// The hand-off waits this long after the stage was last in use.
|
|
18
|
-
|
|
18
|
+
const QUIET_MS = 1500;
|
|
19
19
|
// A touch, wheel or scroll counts as use this long (phones have no hover).
|
|
20
|
-
|
|
20
|
+
const TOUCH_MS = 4000;
|
|
21
21
|
|
|
22
22
|
// One headline sentence as plain text: the markdown a streaming answer
|
|
23
23
|
// carries (headings, quotes, bullets, table pipes, emphasis, code ticks,
|
|
@@ -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
|
},
|
|
@@ -3,9 +3,9 @@ import { waitingOn } from "./notify.js";
|
|
|
3
3
|
// How many session cards the top strip shows for its width in px: a card
|
|
4
4
|
// needs about 360 px plus a 12 px gap next to the 150 px "All sessions" tile;
|
|
5
5
|
// never fewer than 3 (narrow screens scroll them), never more than 6.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
const STRIP_TILE_PX = 150;
|
|
7
|
+
const STRIP_GAP_PX = 12;
|
|
8
|
+
const STRIP_CARD_PX = 360;
|
|
9
9
|
|
|
10
10
|
export function stripColumns(width) {
|
|
11
11
|
const fit = Math.floor(((width || 0) - STRIP_TILE_PX - STRIP_GAP_PX) / (STRIP_CARD_PX + STRIP_GAP_PX));
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
// button disabled. Pure but for the injected calls, so `node --test`
|
|
7
7
|
// covers it; turn_view.js and stage_view.js draw the button.
|
|
8
8
|
import { stopTask as postStopTask } from "./data.js";
|
|
9
|
+
import { clip } from "./format.js";
|
|
9
10
|
|
|
10
11
|
// What a worker that has the route names in its sidecar features.
|
|
11
|
-
|
|
12
|
+
const TASK_STOP_FEATURE = "task_stop";
|
|
12
13
|
const COMMAND_SHOWN = 120;
|
|
13
14
|
|
|
14
15
|
// The task a row's button would stop: a running task_wait's (its server
|
|
@@ -61,7 +62,7 @@ export function taskCommand(gens, taskId) {
|
|
|
61
62
|
|
|
62
63
|
export function confirmText(taskId, command) {
|
|
63
64
|
const line = String(command || "").replace(/\s+/g, " ").trim();
|
|
64
|
-
const what =
|
|
65
|
+
const what = line ? clip(line, COMMAND_SHOWN) : taskId;
|
|
65
66
|
return `Stop task ${what}?`;
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -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
|
+
}
|
|
@@ -26,7 +26,7 @@ export function workerGoneText({ turnRunning, stopped = false }) {
|
|
|
26
26
|
|
|
27
27
|
// How long a turn's answer waits for the after_turn hooks' answer_display
|
|
28
28
|
// before the page stops waiting and pops what it has (4.14).
|
|
29
|
-
|
|
29
|
+
const DISPLAY_WAIT_MS = 20000;
|
|
30
30
|
|
|
31
31
|
// The wait between a turn_completed with display_pending and its
|
|
32
32
|
// answer_display. The worker can die in between (a crash, a stop, an idle
|
|
@@ -8,7 +8,7 @@ import { html, nothing, render } from "./vendor/lit-html.js";
|
|
|
8
8
|
import { commandBlockTemplate, rowHover } from "./command_view.js";
|
|
9
9
|
import { copySource } from "./copy.js";
|
|
10
10
|
import { activityDiffTemplate } from "./diff_view.js";
|
|
11
|
-
import { steerSender } from "./format.js";
|
|
11
|
+
import { clip, steerSender } from "./format.js";
|
|
12
12
|
import { rowTitleRef } from "./refs.js";
|
|
13
13
|
import { readLinesTemplate } from "./read_view.js";
|
|
14
14
|
import { STOPPED_LINE_CLASS, cancelLineText, formatDuration } from "./timing.js";
|
|
@@ -105,7 +105,7 @@ export function toolRowInnerTemplate(row, { thumbs = () => nothing, stop = nothi
|
|
|
105
105
|
const duration = formatDuration(row.duration_ms);
|
|
106
106
|
const full = row.status === "running" ? "" : String(row.output || "").replace(/^\[[^\]]*\]\s*/, "");
|
|
107
107
|
const output = full
|
|
108
|
-
? html`<div class="activity-output" title=${full}>${full
|
|
108
|
+
? html`<div class="activity-output" title=${full}>${clip(full, 301)}</div>`
|
|
109
109
|
: nothing;
|
|
110
110
|
const status = statusParts(row.status, { noMatch: !!row.no_match });
|
|
111
111
|
return html`${statusMarkTemplate(status)}<span class="activity-tool">${toolName(row)}</span>${params}${stop}${alias}${statusWordTemplate(status)}${duration ? html`<span class="activity-duration">${duration}</span>` : nothing}${commandBlockTemplate(row.view, row.title)}${llmEditTemplate(row)}${output}${full ? readLinesTemplate(row) : nothing}${row.images?.length ? thumbs(row.images) : nothing}${row.diff ? activityDiffTemplate(row.diff) : nothing}`;
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// - the turn's end closes the current gen.
|
|
26
26
|
import { addCompleted, addStarted, newActivity, toolName } from "./activity.js";
|
|
27
27
|
import { tallyText } from "./tally.js";
|
|
28
|
-
import { leadTrimmed } from "./format.js";
|
|
28
|
+
import { clip, leadTrimmed } from "./format.js";
|
|
29
29
|
|
|
30
30
|
const LABEL_MAX = 80;
|
|
31
31
|
const LABEL_TOOLS = 3;
|
|
@@ -194,7 +194,7 @@ function isStep(gen) {
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
function cut(line) {
|
|
197
|
-
return line
|
|
197
|
+
return clip(line, LABEL_MAX + 1);
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
// "3 tool calls (1 failed)": failed = status error, the tally's predicate
|
|
@@ -58,7 +58,7 @@ function closedLabel(gen) {
|
|
|
58
58
|
// that would repeat "thinking"). +body+ is the text: a string, or the live
|
|
59
59
|
// step's Text node (appended by delta: a selection in it survives).
|
|
60
60
|
// +cls+ "bubble thinking" is a plain answer's collapsed thinking block.
|
|
61
|
-
|
|
61
|
+
function thinkingTemplate({ body, wrapped = true, line = null, cls = "thinking", on = {} }) {
|
|
62
62
|
const inner = html`<div class="thinking-body">${body}</div>`;
|
|
63
63
|
if (!wrapped) return inner;
|
|
64
64
|
const summary = line === null ? "thinking" : html`<span class="thinking-tag">thinking · </span>${line}`;
|
|
@@ -241,10 +241,6 @@ module Samagotchi
|
|
|
241
241
|
knows_dir(InfoProbe.call(host, port, timeout: timeout))
|
|
242
242
|
end
|
|
243
243
|
|
|
244
|
-
def self.probe_verdict(status, body)
|
|
245
|
-
knows_dir(InfoProbe.verdict(status, body))
|
|
246
|
-
end
|
|
247
|
-
|
|
248
244
|
def self.knows_dir(info)
|
|
249
245
|
info.is_a?(Hash) && !Array(info["features"]).include?("dir") ? :other : info
|
|
250
246
|
end
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -218,7 +218,7 @@ module Samagotchi
|
|
|
218
218
|
# The session, its Engine and what runs its turns, before anyone can
|
|
219
219
|
# reach it.
|
|
220
220
|
def start
|
|
221
|
-
# The wake turns' budget (delegate reports, attached context), from now.
|
|
221
|
+
# The wake turns' budget (delegate reports, attached context, peers), from now.
|
|
222
222
|
@wakes = WorkerWakes.new(grace: WAKE_START_GRACE)
|
|
223
223
|
# This worker holds the session now (a continue's starting marker held
|
|
224
224
|
# off any other until it did), and a continue that died moving
|
|
@@ -272,7 +272,8 @@ module Samagotchi
|
|
|
272
272
|
predecessor: @session.continues)
|
|
273
273
|
# Its attached context's notes (ContextSources).
|
|
274
274
|
@context_absorber = ContextAbsorber.new(session_id: @session_id, state_dir: @state_dir,
|
|
275
|
-
project_root: @session.project_root
|
|
275
|
+
project_root: @session.project_root,
|
|
276
|
+
peer_sources: @engine.peer_sources, peer_turns: -> { @wakes.peer_turns })
|
|
276
277
|
# What comes in between turns, and how each kind is saved.
|
|
277
278
|
@inbound = WorkerInbound.new(session: @session, state_dir: @state_dir, session_dir: @session_dir, engine: @engine,
|
|
278
279
|
context_absorber: @context_absorber, wakes: @wakes,
|
|
@@ -496,13 +497,14 @@ module Samagotchi
|
|
|
496
497
|
# A turn nobody typed for a changed source (a context wake): a continue
|
|
497
498
|
# turn from context:<name> whose first message is the wake note already
|
|
498
499
|
# in the conversation (named by +turn_id+), on the same rules as a
|
|
499
|
-
# delegate report's wake turn: it counts toward session.max_wakes
|
|
500
|
-
#
|
|
501
|
-
#
|
|
500
|
+
# delegate report's wake turn: it counts toward session.max_wakes (a
|
|
501
|
+
# peer's answer toward context.peer_turns too), and a failure goes back
|
|
502
|
+
# to before the turn (the note stays, as a plain one:
|
|
503
|
+
# WorkerInbound#unmark_wake) and pauses wakes until a human's input.
|
|
502
504
|
# +context_wake+: a WorkerInbound::ContextWake.
|
|
503
505
|
def run_context_wake_turn(context_wake)
|
|
504
506
|
name = context_wake.name
|
|
505
|
-
Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes.count!)
|
|
507
|
+
Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes.count!(peer: context_wake.peer?))
|
|
506
508
|
@turn_flow.before_prompt_turn
|
|
507
509
|
run_engine_turn(nil, continue: true, origin: context_wake.origin,
|
|
508
510
|
id: context_wake.turn_id, max_iterations: IterationLimit.for) do |result, error|
|
|
@@ -878,6 +880,8 @@ module Samagotchi
|
|
|
878
880
|
# /reasoning-history or /model: the level the next turn runs under
|
|
879
881
|
# (the web's chip); nil on a native host, which ignores it.
|
|
880
882
|
event[:reasoning_history] = @engine.reasoning_history_explained&.summary if changed.intersect?(%i[reasoning_history model])
|
|
883
|
+
# /toolkit: what the next turn declares (the web's chip; nil on full).
|
|
884
|
+
event[:toolkit] = @engine.toolkit_summary if changed.include?(:toolkit)
|
|
881
885
|
event[:confirm] = confirm.to_h if confirm
|
|
882
886
|
event[:output_truncated] = true if text.length > COMMAND_OUTPUT_LIMIT
|
|
883
887
|
@engine.announce(event)
|
|
@@ -1218,12 +1222,13 @@ module Samagotchi
|
|
|
1218
1222
|
|
|
1219
1223
|
# A human's input (ClientId.human?) un-archives the session;
|
|
1220
1224
|
# a delegate's, a plugin's or a reminder's doesn't.
|
|
1221
|
-
# It also resets the
|
|
1225
|
+
# It also resets the wakes (budget and pause); input typed in the web or
|
|
1226
|
+
# a TUI the peer wakes too (chi send doesn't: a peer can run it).
|
|
1222
1227
|
def user_input(client_id)
|
|
1223
1228
|
return unless ClientId.human?(client_id)
|
|
1224
1229
|
|
|
1225
1230
|
ArchiveStore.user_input(@session_id, state_dir: @state_dir)
|
|
1226
|
-
@wakes.human_input!
|
|
1231
|
+
@wakes.human_input!(interactive: ClientId.interactive?(client_id))
|
|
1227
1232
|
end
|
|
1228
1233
|
|
|
1229
1234
|
def stopped_on_disk?
|
|
@@ -4,12 +4,14 @@ require "fileutils"
|
|
|
4
4
|
require "securerandom"
|
|
5
5
|
require "time"
|
|
6
6
|
|
|
7
|
+
require_relative "child_reports"
|
|
7
8
|
require_relative "client_id"
|
|
8
9
|
require_relative "config"
|
|
9
10
|
require_relative "context_note"
|
|
10
11
|
require_relative "log"
|
|
11
12
|
require_relative "session"
|
|
12
13
|
require_relative "session_inbox"
|
|
14
|
+
require_relative "worker_wakes"
|
|
13
15
|
|
|
14
16
|
module Samagotchi
|
|
15
17
|
# What comes into a worker's session between turns, each kind with its
|
|
@@ -43,6 +45,8 @@ module Samagotchi
|
|
|
43
45
|
# the turn +turn_id+.
|
|
44
46
|
ContextWake = Data.define(:delivery, :turn_id) do
|
|
45
47
|
def name = delivery.name
|
|
48
|
+
# For a peer agent's answer (its own budget, WorkerWakes#peer_open?).
|
|
49
|
+
def peer? = !delivery.peer.nil?
|
|
46
50
|
def origin = { client_id: "#{ClientId::CONTEXT_PREFIX}#{name}" }
|
|
47
51
|
end
|
|
48
52
|
|
|
@@ -230,14 +234,42 @@ module Samagotchi
|
|
|
230
234
|
# context.wake is on, the wake budget is open (WorkerWakes#context_open?:
|
|
231
235
|
# no continue offer, not paused, under session.max_wakes, past the start
|
|
232
236
|
# grace) and the source hasn't woken the session in the last
|
|
233
|
-
# CONTEXT_WAKE_WINDOW
|
|
234
|
-
#
|
|
237
|
+
# CONTEXT_WAKE_WINDOW; a peer's answer (Delivery#peer) has no window but
|
|
238
|
+
# the peer budget (WorkerWakes#peer_open?). The others stay plain notes.
|
|
239
|
+
# Within the start grace a change is a note: it came while the session
|
|
240
|
+
# was away. A peer's answer held by either budget is announced once.
|
|
235
241
|
def context_wake_for(batch, now: Time.now)
|
|
236
242
|
candidates = batch.deliveries.select(&:wake_note)
|
|
237
243
|
return nil if candidates.empty? || !context_wakes_on?
|
|
238
|
-
return nil unless @wakes.context_open?(awaiting_continue: @awaiting_continue.call, names: candidates.map(&:name))
|
|
239
244
|
|
|
240
|
-
|
|
245
|
+
unless @wakes.context_open?(awaiting_continue: @awaiting_continue.call, names: candidates.map(&:name))
|
|
246
|
+
peer_held(candidates.find(&:peer), ChildRing::MAX_WAKES_KEY) if @wakes.spent?
|
|
247
|
+
return nil
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
held = []
|
|
251
|
+
waking = candidates.select do |delivery|
|
|
252
|
+
next !woke_lately?(delivery.subscription, now) unless delivery.peer
|
|
253
|
+
|
|
254
|
+
next true if @wakes.peer_open?(name: delivery.name)
|
|
255
|
+
|
|
256
|
+
held << delivery
|
|
257
|
+
false
|
|
258
|
+
end
|
|
259
|
+
peer_held(held.first, WorkerWakes::PEER_TURNS_KEY)
|
|
260
|
+
waking.first
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# A peer's answer waits as a note: the UIs hear it once per
|
|
264
|
+
# interactive input (WorkerWakes#notice_peer_budget!). +key+: the
|
|
265
|
+
# budget that holds it.
|
|
266
|
+
def peer_held(delivery, key)
|
|
267
|
+
return unless delivery && @wakes.notice_peer_budget!
|
|
268
|
+
|
|
269
|
+
turns = key == ChildRing::MAX_WAKES_KEY ? @wakes.max : @wakes.peer_turns
|
|
270
|
+
@engine.announce(type: :hook_notice, hook: "context", level: :info, between_turns: true,
|
|
271
|
+
text: "#{delivery.peer} answered again; it waits as a note for your next message " \
|
|
272
|
+
"(#{turns} wake turn#{"s" if turns != 1} in a row, #{key})")
|
|
241
273
|
end
|
|
242
274
|
|
|
243
275
|
def context_wakes_on? = Config.get("context.wake") != false
|