samagotchi 0.3.0 → 0.5.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 +162 -1
- data/README.md +29 -2
- data/bin/chi +60 -69
- data/docs/cli.md +211 -77
- data/docs/configuration.md +118 -21
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +89 -7
- data/docs/memory.md +40 -0
- data/docs/plugins.md +50 -0
- data/docs/releasing.md +15 -12
- data/docs/sessions.md +20 -18
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bridge.rb +20 -12
- data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
- data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/identity.md +5 -0
- data/lib/samagotchi/bundles/system/manifest.yml +6 -6
- data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client.rb +25 -26
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +97 -113
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/engine.rb +236 -443
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +59 -123
- data/lib/samagotchi/live_versions.rb +65 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +38 -7
- data/lib/samagotchi/llm/http.rb +19 -22
- data/lib/samagotchi/llm/openai_chat.rb +22 -26
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +27 -10
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +48 -4
- data/lib/samagotchi/self_report.rb +37 -5
- data/lib/samagotchi/send_command.rb +190 -17
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +19 -53
- data/lib/samagotchi/system_prompt.rb +403 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
- data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
- data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
- data/lib/samagotchi/terminal_ui/surface.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +142 -923
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +126 -0
- data/lib/samagotchi/tool_activity.rb +52 -2
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +3 -3
- data/lib/samagotchi/tools/output_guardrails.rb +8 -7
- data/lib/samagotchi/tools/read.rb +4 -4
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/update_command.rb +309 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +6 -4
- data/lib/samagotchi/web/app.rb +173 -38
- data/lib/samagotchi/web/lan.rb +99 -0
- data/lib/samagotchi/web/message_parts.rb +19 -10
- data/lib/samagotchi/web/public/activity.js +10 -0
- data/lib/samagotchi/web/public/app.js +135 -78
- data/lib/samagotchi/web/public/chat_view.js +8 -1
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +185 -18
- data/lib/samagotchi/web/public/model_pick.js +136 -0
- data/lib/samagotchi/web/public/model_picker.js +224 -0
- data/lib/samagotchi/web/public/notify.js +10 -0
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/stage_model.js +110 -0
- data/lib/samagotchi/web/public/stage_view.js +580 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +38 -10
- data/lib/samagotchi/web/public/turn_model.js +11 -3
- data/lib/samagotchi/web/public/turn_view.js +76 -20
- data/lib/samagotchi/web/qr.rb +40 -0
- data/lib/samagotchi/web/server.rb +101 -11
- data/lib/samagotchi/web/token.rb +97 -0
- data/lib/samagotchi/worker.rb +5 -4
- metadata +38 -3
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
|
@@ -169,18 +169,26 @@ export function applyEvent(turn, event) {
|
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
function cut(line) {
|
|
173
|
+
return line.length > LABEL_MAX ? `${line.slice(0, LABEL_MAX).trimEnd()}…` : line;
|
|
174
|
+
}
|
|
175
|
+
|
|
172
176
|
function toolCallsText(n) {
|
|
173
177
|
return `${n} tool call${n === 1 ? "" : "s"}`;
|
|
174
178
|
}
|
|
175
179
|
|
|
176
|
-
// The collapsed row of a gen: its narration's first line, else
|
|
177
|
-
//
|
|
180
|
+
// The collapsed row of a gen: its narration's first line, else its first
|
|
181
|
+
// call's title ("edit lib/a.rb"), else the tools it worked with, else
|
|
182
|
+
// "thinking"; then its call count.
|
|
178
183
|
export function genLabel(gen) {
|
|
179
184
|
const line = String(gen.text || "").trim().split("\n")[0].trim();
|
|
180
185
|
const calls = gen.tools.length;
|
|
186
|
+
const first = gen.tools[0];
|
|
181
187
|
let head;
|
|
182
188
|
if (line) {
|
|
183
|
-
head = line
|
|
189
|
+
head = cut(line);
|
|
190
|
+
} else if (first?.title) {
|
|
191
|
+
head = cut(`${toolName(first)} ${first.title}`);
|
|
184
192
|
} else if (calls) {
|
|
185
193
|
const names = [...new Set(gen.tools.map(toolName))];
|
|
186
194
|
head = `working with ${names.slice(0, LABEL_TOOLS).join(", ")}${names.length > LABEL_TOOLS ? ", …" : ""}`;
|
|
@@ -13,6 +13,7 @@ import { toolName } from "./activity.js";
|
|
|
13
13
|
import { leavesBlock } from "./card.js";
|
|
14
14
|
import { activityStatusClass, activityStatusLabel, fillActivityRow } from "./chat_view.js";
|
|
15
15
|
import { copySourceAttr } from "./copy.js";
|
|
16
|
+
import { activityDiffHtml } from "./diff_view.js";
|
|
16
17
|
import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
|
|
17
18
|
import { shouldFollowScroll } from "./scroll.js";
|
|
18
19
|
import { hookNoticeLabel } from "./turn_events.js";
|
|
@@ -22,7 +23,31 @@ import { createTicker } from "./thinking_ticker.js";
|
|
|
22
23
|
import * as model from "./turn_model.js";
|
|
23
24
|
import { createHold } from "./hold.js";
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
// The container seams (the stage view composes this view, stage_view.js):
|
|
27
|
+
// blockHost(el) puts the turn's block in the page (default: the history);
|
|
28
|
+
// follow() keeps the live part in view (default: the history's
|
|
29
|
+
// tail), called only when isNearBottom() said so before
|
|
30
|
+
// the change;
|
|
31
|
+
// placeAnswer(pieces, { plain, blockEl }) puts the promoted answer (a
|
|
32
|
+
// plain answer's thinking, cards, bubble) in the page
|
|
33
|
+
// (default: in the block's place for a plain answer,
|
|
34
|
+
// else after it in the history);
|
|
35
|
+
// useHold the answer's pop at the narration box's height (hold.js);
|
|
36
|
+
// newestFirst a new step goes on top of the block while the turn runs
|
|
37
|
+
// (chronological() restores the order);
|
|
38
|
+
// onChange() after anything in the turn changed (the stage redraws
|
|
39
|
+
// its slots from current());
|
|
40
|
+
// onThinkingLine(sentence) the live step's ticker showed a new line.
|
|
41
|
+
export function createTurnView({
|
|
42
|
+
historyEl, appendToHistory, isNearBottom, sawText, sessionId,
|
|
43
|
+
blockHost = appendToHistory,
|
|
44
|
+
follow = () => { historyEl.scrollTop = historyEl.scrollHeight; },
|
|
45
|
+
placeAnswer = defaultPlaceAnswer(appendToHistory),
|
|
46
|
+
useHold = true,
|
|
47
|
+
newestFirst = false,
|
|
48
|
+
onChange = () => {},
|
|
49
|
+
onThinkingLine = () => {},
|
|
50
|
+
}) {
|
|
26
51
|
let turn = null;
|
|
27
52
|
let blockEl = null;
|
|
28
53
|
let blockSummaryEl = null;
|
|
@@ -35,7 +60,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
35
60
|
let rafPending = false;
|
|
36
61
|
// The promoted answer bubble while it is held at the box's height (the
|
|
37
62
|
// pop, D6).
|
|
38
|
-
const hold = createHold({ isNearBottom, follow
|
|
63
|
+
const hold = createHold({ isNearBottom, follow });
|
|
39
64
|
|
|
40
65
|
function removeHint() {
|
|
41
66
|
const hint = historyEl.querySelector(".hint");
|
|
@@ -57,7 +82,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
57
82
|
blockEl.appendChild(blockSummaryEl);
|
|
58
83
|
rememberToggle(blockSummaryEl, () => { blockToggled = true; });
|
|
59
84
|
updateBlockSummary();
|
|
60
|
-
|
|
85
|
+
blockHost(blockEl);
|
|
61
86
|
}
|
|
62
87
|
|
|
63
88
|
function updateBlockSummary() {
|
|
@@ -85,9 +110,10 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
85
110
|
feed: createSentenceFeed(), box: null };
|
|
86
111
|
rememberToggle(summary, () => { entry.toggled = true; });
|
|
87
112
|
genEls.set(gen, entry);
|
|
88
|
-
const
|
|
89
|
-
blockEl.
|
|
90
|
-
|
|
113
|
+
const stick = isNearBottom();
|
|
114
|
+
if (newestFirst) blockEl.insertBefore(el, blockSummaryEl.nextSibling);
|
|
115
|
+
else blockEl.appendChild(el);
|
|
116
|
+
if (stick) follow();
|
|
91
117
|
return entry;
|
|
92
118
|
}
|
|
93
119
|
|
|
@@ -134,6 +160,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
134
160
|
entry.thinkingLine.classList.remove("thinking-fade");
|
|
135
161
|
void entry.thinkingLine.offsetWidth;
|
|
136
162
|
entry.thinkingLine.classList.add("thinking-fade");
|
|
163
|
+
onThinkingLine(sentence);
|
|
137
164
|
}
|
|
138
165
|
|
|
139
166
|
// Feed the step's ticker from its accumulated thinking; the caller's timer
|
|
@@ -227,10 +254,11 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
227
254
|
rafPending = false;
|
|
228
255
|
// Runs every animation frame while streaming — must not yank a user who
|
|
229
256
|
// scrolled up to read; only follow when they were already near the bottom.
|
|
230
|
-
const
|
|
257
|
+
const stick = isNearBottom();
|
|
231
258
|
for (const gen of dirty) writeGen(gen, { follow: true });
|
|
232
259
|
dirty.clear();
|
|
233
|
-
if (follow)
|
|
260
|
+
if (stick) follow();
|
|
261
|
+
onChange();
|
|
234
262
|
}
|
|
235
263
|
|
|
236
264
|
function scheduleFlush(gen) {
|
|
@@ -290,6 +318,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
290
318
|
if (r.closed) closeGenEl(r.closed);
|
|
291
319
|
if (r.opened) createGenEl(r.gen);
|
|
292
320
|
if (r.closed || r.opened) updateBlockSummary();
|
|
321
|
+
onChange();
|
|
293
322
|
}
|
|
294
323
|
|
|
295
324
|
function toolEvent(r) {
|
|
@@ -310,14 +339,15 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
310
339
|
}
|
|
311
340
|
el.querySelector(".activity-output").style.display = "none";
|
|
312
341
|
entry.rows.set(r.row.key, el);
|
|
313
|
-
const
|
|
342
|
+
const stick = isNearBottom();
|
|
314
343
|
body.appendChild(el);
|
|
315
344
|
fillActivityRow(el, r.row, sessionId());
|
|
316
|
-
if (follow)
|
|
345
|
+
if (stick) follow();
|
|
317
346
|
} else {
|
|
318
347
|
fillActivityRow(el, r.row, sessionId());
|
|
319
348
|
}
|
|
320
349
|
updateBlockSummary();
|
|
350
|
+
onChange();
|
|
321
351
|
}
|
|
322
352
|
|
|
323
353
|
// A hook's line (hook_notice) as a row of the current step, above the row
|
|
@@ -332,9 +362,9 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
332
362
|
const el = document.createElement("div");
|
|
333
363
|
el.className = "hook-notice" + (data.level === "warn" ? " warn" : "");
|
|
334
364
|
el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
|
|
335
|
-
const
|
|
365
|
+
const stick = isNearBottom();
|
|
336
366
|
activityEl(r.gen).appendChild(el);
|
|
337
|
-
if (follow)
|
|
367
|
+
if (stick) follow();
|
|
338
368
|
return true;
|
|
339
369
|
}
|
|
340
370
|
|
|
@@ -367,9 +397,9 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
367
397
|
const entry = gen && genEls.get(gen);
|
|
368
398
|
if (!entry) return false;
|
|
369
399
|
if (!gen.text) entry.text.textContent = "";
|
|
370
|
-
const
|
|
400
|
+
const stick = isNearBottom();
|
|
371
401
|
activityEl(gen).appendChild(el);
|
|
372
|
-
if (follow)
|
|
402
|
+
if (stick) follow();
|
|
373
403
|
return true;
|
|
374
404
|
}
|
|
375
405
|
|
|
@@ -383,7 +413,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
383
413
|
bubble.className = "bubble output";
|
|
384
414
|
if (!gen.text) bubble.textContent = "";
|
|
385
415
|
// A hidden tab gets no pop: nobody sees it, and its timers are throttled.
|
|
386
|
-
const holding = heldHeight > 0 && !!gen.text && !document.hidden;
|
|
416
|
+
const holding = useHold && heldHeight > 0 && !!gen.text && !document.hidden;
|
|
387
417
|
let holdHeight = 0;
|
|
388
418
|
if (holding) {
|
|
389
419
|
// Bubbles are border-box: the box's height plus the bubble's own
|
|
@@ -424,12 +454,11 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
424
454
|
}
|
|
425
455
|
pieces.push(...cards);
|
|
426
456
|
if (gen.text) pieces.push(bubble);
|
|
427
|
-
|
|
457
|
+
placeAnswer(pieces, { plain: true, blockEl });
|
|
428
458
|
blockEl = null;
|
|
429
459
|
blockSummaryEl = null;
|
|
430
460
|
} else {
|
|
431
|
-
|
|
432
|
-
if (gen.text) appendToHistory(bubble);
|
|
461
|
+
placeAnswer(gen.text ? [...cards, bubble] : cards, { plain: false, blockEl });
|
|
433
462
|
// The last step, left with its thinking only, reads "thinking" once.
|
|
434
463
|
if (!gen.tools.length && gen.thinking) unwrapThinking(entry);
|
|
435
464
|
}
|
|
@@ -467,6 +496,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
467
496
|
}
|
|
468
497
|
dirty.clear();
|
|
469
498
|
rafPending = false;
|
|
499
|
+
onChange();
|
|
470
500
|
return kept;
|
|
471
501
|
}
|
|
472
502
|
|
|
@@ -544,6 +574,30 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
544
574
|
return !!entry && (entry.text.contains(el) || !!entry.thinking?.contains(el));
|
|
545
575
|
},
|
|
546
576
|
reset,
|
|
577
|
+
// For a view that composes this one (the stage): the turn's model and
|
|
578
|
+
// block, a step's elements by index, and the steps back in
|
|
579
|
+
// chronological order after newestFirst.
|
|
580
|
+
current() { return { turn, blockEl }; },
|
|
581
|
+
step(i) {
|
|
582
|
+
const entry = turn && genEls.get(turn.gens[i]);
|
|
583
|
+
return entry ? { el: entry.el, summary: entry.summary, thinkingWrap: entry.thinkingWrap } : null;
|
|
584
|
+
},
|
|
585
|
+
chronological() {
|
|
586
|
+
if (!blockEl || !turn) return;
|
|
587
|
+
for (const gen of turn.gens) {
|
|
588
|
+
const el = genEls.get(gen)?.el;
|
|
589
|
+
if (el?.parentNode === blockEl) blockEl.appendChild(el);
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// The turn view's answer placement: a plain answer's pieces in its block's
|
|
596
|
+
// place (the block goes), else after the block in the history.
|
|
597
|
+
function defaultPlaceAnswer(appendToHistory) {
|
|
598
|
+
return (pieces, { plain, blockEl }) => {
|
|
599
|
+
if (plain) blockEl.replaceWith(...pieces);
|
|
600
|
+
else pieces.forEach((el) => appendToHistory(el));
|
|
547
601
|
};
|
|
548
602
|
}
|
|
549
603
|
|
|
@@ -554,7 +608,9 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
554
608
|
// parts; without them the row has the record's name, status and duration
|
|
555
609
|
// only. +thumbs(images)+ draws the images, as the live row does.
|
|
556
610
|
function reloadRowHtml(r, thumbs) {
|
|
557
|
-
const
|
|
611
|
+
const shown = r.title || r.params;
|
|
612
|
+
const hover = r.title && r.params ? ` title="${escapeHtml(r.params)}"` : "";
|
|
613
|
+
const params = shown ? `<span class="activity-params"${hover}>${escapeHtml(shown)}</span>` : "";
|
|
558
614
|
const duration = formatDuration(r.duration_ms);
|
|
559
615
|
const full = String(r.output || "").replace(/^\[[^\]]*\]\s*/, "");
|
|
560
616
|
const output = full
|
|
@@ -562,7 +618,7 @@ function reloadRowHtml(r, thumbs) {
|
|
|
562
618
|
: "";
|
|
563
619
|
return `<div class="activity-row" data-key="${escapeHtml(r.key)}"><span class="activity-status ${activityStatusClass(r.status)}">${activityStatusLabel(r.status)}</span>` +
|
|
564
620
|
`<span class="activity-tool">${escapeHtml(toolName(r))}</span>${params}${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}${output}` +
|
|
565
|
-
`${r.images?.length ? thumbs(r.images) : ""}</div>`;
|
|
621
|
+
`${r.images?.length ? thumbs(r.images) : ""}${r.diff ? activityDiffHtml(r.diff) : ""}</div>`;
|
|
566
622
|
}
|
|
567
623
|
|
|
568
624
|
// A reloaded history in the turn view (renderHistory's object path): per
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rqrcode_core"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Web
|
|
7
|
+
# A QR code for the terminal: two module rows per line in half blocks
|
|
8
|
+
# (▀ ▄ █), black on white whatever the terminal's theme, so a phone's
|
|
9
|
+
# camera reads it on a dark terminal too.
|
|
10
|
+
module QR
|
|
11
|
+
# Modules of white around the code. The standard asks for 4; the
|
|
12
|
+
# white background makes 2 enough for a phone at a screen.
|
|
13
|
+
QUIET = 2
|
|
14
|
+
COLORS = "\e[30;107m"
|
|
15
|
+
RESET = "\e[0m"
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# @return [Array<String>] the lines, colors included
|
|
20
|
+
def lines(text, quiet: QUIET, color: true)
|
|
21
|
+
rows = matrix(text, quiet: quiet)
|
|
22
|
+
rows << Array.new(rows.first.size, false) if rows.size.odd?
|
|
23
|
+
rows.each_slice(2).map do |top, bottom|
|
|
24
|
+
line = top.zip(bottom).map { |t, b| t ? (b ? "█" : "▀") : (b ? "▄" : " ") }.join
|
|
25
|
+
color ? "#{COLORS}#{line}#{RESET}" : line
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Dark modules as true, the quiet zone around them.
|
|
30
|
+
# Level L: the code is read off a screen, never damaged, and stays small.
|
|
31
|
+
def matrix(text, quiet: QUIET)
|
|
32
|
+
code = RQRCodeCore::QRCode.new(text, level: :l)
|
|
33
|
+
size = code.module_count
|
|
34
|
+
blank = Array.new(size + (2 * quiet), false)
|
|
35
|
+
body = Array.new(size) { |r| Array.new(quiet, false) + Array.new(size) { |c| code.checked?(r, c) } + Array.new(quiet, false) }
|
|
36
|
+
Array.new(quiet) { blank.dup } + body + Array.new(quiet) { blank.dup }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -8,7 +8,10 @@ require "uri"
|
|
|
8
8
|
require "webrick"
|
|
9
9
|
|
|
10
10
|
require_relative "app"
|
|
11
|
+
require_relative "lan"
|
|
12
|
+
require_relative "qr"
|
|
11
13
|
require_relative "session_hub"
|
|
14
|
+
require_relative "token"
|
|
12
15
|
require_relative "../config"
|
|
13
16
|
require_relative "../project_scope"
|
|
14
17
|
require_relative "../log"
|
|
@@ -18,7 +21,9 @@ module Samagotchi
|
|
|
18
21
|
module Web
|
|
19
22
|
# Launcher for the single-port Web UI.
|
|
20
23
|
#
|
|
21
|
-
# Binds
|
|
24
|
+
# Binds to loopback (127.0.0.1 by default); with web.host lan (or a LAN
|
|
25
|
+
# address) also to that address, where requests need the access token.
|
|
26
|
+
# Use `bin/chi web` to start.
|
|
22
27
|
class Server
|
|
23
28
|
# WEBrick logs every exception out of its request loop as an ERROR with
|
|
24
29
|
# a backtrace, among them a browser dropping a kept-alive connection
|
|
@@ -51,25 +56,44 @@ module Samagotchi
|
|
|
51
56
|
# URL. If a chi web already runs on the port, print (and with --open,
|
|
52
57
|
# open) its page for +dir+ and leave it be; else start one.
|
|
53
58
|
# @param scope ["project", "all"] "all": the plain page, every session
|
|
59
|
+
# @param new_token [Boolean] replace the LAN access token first
|
|
60
|
+
# (--new-token): a running server takes the new one at once
|
|
54
61
|
# @return [Integer] the exit status
|
|
55
|
-
def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false,
|
|
56
|
-
annotate_presets: Config::BY_KEY["web.annotate_presets"].default)
|
|
57
|
-
|
|
62
|
+
def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false, view: "turn",
|
|
63
|
+
annotate_presets: Config::BY_KEY["web.annotate_presets"].default, new_token: false)
|
|
64
|
+
rotate_token if new_token
|
|
65
|
+
setting = host_setting(host)
|
|
66
|
+
lan = Lan.wanted?(setting) ? Lan.choose(setting) : nil
|
|
67
|
+
host = resolve_host(setting)
|
|
58
68
|
port = resolve_port(port)
|
|
59
69
|
url = scope_url(host, port, dir: dir, scope: scope)
|
|
60
70
|
found = probe(host, port)
|
|
61
71
|
case found
|
|
62
72
|
when Hash
|
|
73
|
+
if lan && !found["lan"]
|
|
74
|
+
warn "chi web already runs on port #{port} without LAN access; stop it (Ctrl-C in its terminal, " \
|
|
75
|
+
"or kill #{found["pid"]}) and run this again"
|
|
76
|
+
return 1
|
|
77
|
+
end
|
|
63
78
|
puts "chi web already runs on port #{port} (pid #{found["pid"]}): #{url}"
|
|
79
|
+
puts running_lan_lines(found["lan"], port) if found["lan"]
|
|
80
|
+
puts lan_off_line(setting) if new_token && !found["lan"]
|
|
64
81
|
open_url(url) if open_browser
|
|
65
82
|
0
|
|
66
83
|
when :other
|
|
67
84
|
warn in_use_message(port)
|
|
68
85
|
1
|
|
69
86
|
else
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
if new_token && !lan
|
|
88
|
+
puts lan_off_line(setting)
|
|
89
|
+
return 0
|
|
90
|
+
end
|
|
91
|
+
start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, view: view,
|
|
92
|
+
annotate_presets: annotate_presets, lan: lan) ? 0 : 1
|
|
72
93
|
end
|
|
94
|
+
rescue Lan::Error => e
|
|
95
|
+
warn "Error: #{e.message}"
|
|
96
|
+
1
|
|
73
97
|
rescue Interrupt
|
|
74
98
|
# Ctrl-C: the server has stopped (start's ensure); one line, and the
|
|
75
99
|
# status a shell gives a command it interrupted.
|
|
@@ -79,22 +103,32 @@ module Samagotchi
|
|
|
79
103
|
|
|
80
104
|
# @param hub [SessionHub, nil] the session projection the page streams
|
|
81
105
|
# from; built over the app's state dir unless given
|
|
106
|
+
# @param lan [Lan::Choice, nil] also listen on this LAN address, where
|
|
107
|
+
# every request but this machine's needs the access token
|
|
108
|
+
# @param token_path [String] the LAN access token's file
|
|
82
109
|
# @return [Boolean] false when the port was taken (said so on stderr)
|
|
83
110
|
def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
|
|
84
|
-
|
|
111
|
+
view: "turn", annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
|
|
112
|
+
token_path: Token.path)
|
|
85
113
|
port = resolve_port(port)
|
|
86
114
|
host = resolve_host(host)
|
|
87
115
|
url ||= "http://#{url_host(host)}:#{port}/"
|
|
88
116
|
|
|
89
117
|
hub ||= SessionHub.new(state_dir: state_dir || Session.default_state_dir, manager: manager || SessionManager)
|
|
90
|
-
|
|
91
|
-
|
|
118
|
+
if lan
|
|
119
|
+
Token.load_or_create(token_path)
|
|
120
|
+
lan_option = { ip: lan.ip, token: Token::Source.new(token_path) }
|
|
121
|
+
end
|
|
122
|
+
app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, view: view,
|
|
123
|
+
annotate_presets: annotate_presets, hub: hub, lan: lan_option)
|
|
92
124
|
Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
|
|
125
|
+
Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
|
|
93
126
|
hub.start
|
|
94
127
|
# Said once the port is bound: a second chi web racing for it gets
|
|
95
128
|
# the in-use line instead.
|
|
96
129
|
started = lambda do
|
|
97
130
|
puts "Chi Web on #{url} (public: #{File.expand_path("public", __dir__)})"
|
|
131
|
+
puts lan_lines(ip: lan.ip, port: port, token: Token.read(token_path), others: lan.others, public: lan.public) if lan
|
|
98
132
|
puts "Press Ctrl-C to stop."
|
|
99
133
|
$stdout.flush # a log file isn't line-buffered
|
|
100
134
|
if open_browser
|
|
@@ -110,20 +144,44 @@ module Samagotchi
|
|
|
110
144
|
Rackup::Handler::WEBrick.run(app, Host: host, Port: port, AccessLog: [], Logger: Log.new($stderr, WEBrick::Log::WARN),
|
|
111
145
|
StartCallback: started) do |server|
|
|
112
146
|
app.server_running = -> { server.status == :Running }
|
|
147
|
+
listen_lan(server, lan, port) if lan
|
|
113
148
|
end
|
|
114
149
|
true
|
|
115
150
|
rescue Errno::EADDRINUSE
|
|
116
151
|
warn in_use_message(port)
|
|
117
152
|
false
|
|
153
|
+
rescue LanListenError => e
|
|
154
|
+
warn "Error: can't listen on #{lan.ip}:#{port} (#{e.message}); did the address change? Run chi web again"
|
|
155
|
+
false
|
|
118
156
|
ensure
|
|
119
157
|
hub&.stop
|
|
120
158
|
Samagotchi::Log.info(:web, "stop") if app
|
|
121
159
|
end
|
|
122
160
|
|
|
161
|
+
class LanListenError < StandardError; end
|
|
162
|
+
|
|
163
|
+
# The LAN address's listener, next to the loopback one WEBrick made.
|
|
164
|
+
# The loopback socket is bound by now: on a failure it is closed
|
|
165
|
+
# here, as WEBrick never starts.
|
|
166
|
+
def self.listen_lan(server, lan, port)
|
|
167
|
+
server.listen(lan.ip, port)
|
|
168
|
+
rescue Errno::EADDRNOTAVAIL, Errno::EADDRINUSE, SocketError => e
|
|
169
|
+
server.listeners.each(&:close)
|
|
170
|
+
raise LanListenError, e.message
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def self.host_setting(host)
|
|
174
|
+
(host || Samagotchi::Config.get("web.host")).to_s.strip
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The address the server binds: loopback. For web.host lan or a LAN
|
|
178
|
+
# address it is 127.0.0.1, and the LAN address is a second listener
|
|
179
|
+
# (start's lan:).
|
|
123
180
|
def self.resolve_host(host)
|
|
124
|
-
host = (host
|
|
181
|
+
host = host_setting(host)
|
|
125
182
|
host = DEFAULT_HOST if host.empty?
|
|
126
183
|
return host if %w[127.0.0.1 ::1 localhost].include?(host)
|
|
184
|
+
return DEFAULT_HOST if Lan.wanted?(host)
|
|
127
185
|
|
|
128
186
|
Samagotchi::Log.warn(:web, "bind_forced", echo: "Web server only binds to 127.0.0.1 (got #{host}); forcing 127.0.0.1", host: host.to_s)
|
|
129
187
|
DEFAULT_HOST
|
|
@@ -162,12 +220,44 @@ module Samagotchi
|
|
|
162
220
|
:other
|
|
163
221
|
end
|
|
164
222
|
|
|
223
|
+
# What a phone needs: the LAN link with the token, the warnings, and
|
|
224
|
+
# the link's QR code (at a terminal only).
|
|
225
|
+
def self.lan_lines(ip:, port:, token:, others: [], public: false, qr: $stdout.tty?)
|
|
226
|
+
unless token
|
|
227
|
+
return ["LAN: http://#{ip}:#{port}/ (no access token: its file is gone; chi web --new-token makes one)"]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
link = "http://#{ip}:#{port}/?token=#{token}"
|
|
231
|
+
lines = ["LAN: #{link} ← anyone with this link can run commands as you",
|
|
232
|
+
"Plain http: the link and your traffic can be read by anyone on this Wi-Fi."]
|
|
233
|
+
lines << "also: #{others.map(&:to_s).join(", ")}; set web.host to pick one" unless others.empty?
|
|
234
|
+
lines << "#{ip} isn't a private LAN address: anyone who can reach it can try to get in" if public
|
|
235
|
+
lines.concat(QR.lines(link)) if qr
|
|
236
|
+
lines
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# A second chi web, when the running one has LAN access: the same
|
|
240
|
+
# lines, the token read from its file here.
|
|
241
|
+
def self.running_lan_lines(ip, port, token_path: Token.path)
|
|
242
|
+
lan_lines(ip: ip, port: port, token: Token.read(token_path))
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def self.lan_off_line(setting)
|
|
246
|
+
"LAN access is off (web.host is #{setting.empty? ? DEFAULT_HOST : setting}): chi web --web-host lan uses the new token"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def self.rotate_token(path = Token.path)
|
|
250
|
+
Token.rotate(path)
|
|
251
|
+
Samagotchi::Log.info(:web, "token_rotated")
|
|
252
|
+
puts "New LAN access token: links and QR codes made with the old one stop working."
|
|
253
|
+
end
|
|
254
|
+
|
|
165
255
|
def self.in_use_message(port)
|
|
166
256
|
"Error: port #{port} is in use (an older chi web? restart it, or use --port)"
|
|
167
257
|
end
|
|
168
258
|
|
|
169
259
|
def self.resolve_port(port)
|
|
170
|
-
raw = port ||
|
|
260
|
+
raw = port || Samagotchi::Config.get("web.port")
|
|
171
261
|
parsed = raw.to_s.to_i
|
|
172
262
|
parsed.positive? ? parsed : DEFAULT_PORT
|
|
173
263
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
require_relative "../session"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
module Web
|
|
10
|
+
# The access token of `chi web` on the LAN (web.host: lan): anyone
|
|
11
|
+
# who has it can run commands as you, so it lives in one file only you
|
|
12
|
+
# can read, $XDG_STATE_HOME/samagotchi/web-token (0600). It is made on
|
|
13
|
+
# the first LAN start and kept, so a phone's bookmark survives
|
|
14
|
+
# restarts; `chi web --new-token` replaces it. A loopback-only chi web
|
|
15
|
+
# never reads it.
|
|
16
|
+
module Token
|
|
17
|
+
FILE = "web-token"
|
|
18
|
+
# 32 random bytes: 43 URL-safe characters.
|
|
19
|
+
BYTES = 32
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def path(env: ENV)
|
|
24
|
+
File.join(File.dirname(Session.default_state_dir(env: env)), FILE)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [String, nil] the token in +path+, nil when there is none
|
|
28
|
+
def read(path = self.path)
|
|
29
|
+
token = File.read(path).strip
|
|
30
|
+
token.empty? ? nil : token
|
|
31
|
+
rescue Errno::ENOENT
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [String] the token in +path+, made (and saved) if there is none
|
|
36
|
+
def load_or_create(path = self.path)
|
|
37
|
+
read(path) || write(path, generate)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A new token in +path+: every link made with the old one stops working.
|
|
41
|
+
# @return [String] the new token
|
|
42
|
+
def rotate(path = self.path)
|
|
43
|
+
write(path, generate)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def generate
|
|
47
|
+
SecureRandom.urlsafe_base64(BYTES)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Written whole under a temporary name, then renamed over +path+: a
|
|
51
|
+
# reader never sees half a token. The file is 0600 from the start,
|
|
52
|
+
# its folder 0700 when this makes it.
|
|
53
|
+
def write(path, token)
|
|
54
|
+
dir = File.dirname(path)
|
|
55
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
56
|
+
tmp = File.join(dir, ".#{File.basename(path)}.#{Process.pid}.#{SecureRandom.hex(4)}")
|
|
57
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |io| io.write("#{token}\n") }
|
|
58
|
+
File.rename(tmp, path)
|
|
59
|
+
token
|
|
60
|
+
ensure
|
|
61
|
+
FileUtils.rm_f(tmp) if tmp && File.exist?(tmp)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The token a running server checks against: the file is re-read when
|
|
65
|
+
# its mtime changes (one stat per check), so `chi web --new-token`
|
|
66
|
+
# takes effect at once, without a restart.
|
|
67
|
+
class Source
|
|
68
|
+
attr_reader :path
|
|
69
|
+
|
|
70
|
+
def initialize(path = Token.path)
|
|
71
|
+
@path = path
|
|
72
|
+
@mutex = Mutex.new
|
|
73
|
+
@stamp = nil
|
|
74
|
+
@token = nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [String, nil] the current token; nil when the file is gone
|
|
78
|
+
# (then nothing matches)
|
|
79
|
+
def current
|
|
80
|
+
stamp = begin
|
|
81
|
+
stat = File.stat(@path)
|
|
82
|
+
[stat.mtime, stat.size, stat.ino]
|
|
83
|
+
rescue Errno::ENOENT
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
unless stamp == @stamp
|
|
88
|
+
@token = stamp && Token.read(@path)
|
|
89
|
+
@stamp = stamp
|
|
90
|
+
end
|
|
91
|
+
@token
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -318,6 +318,7 @@ module Samagotchi
|
|
|
318
318
|
@engine.clear_due_reminder_names!
|
|
319
319
|
return false unless @engine.reminders_due?
|
|
320
320
|
|
|
321
|
+
drop_continue_offer({ client_id: SessionManager::REMINDER_CLIENT_ID })
|
|
321
322
|
@session.status = Session::STATUS_RUNNING
|
|
322
323
|
@session.save(state_dir: @state_dir)
|
|
323
324
|
begin
|
|
@@ -331,8 +332,8 @@ module Samagotchi
|
|
|
331
332
|
ensure
|
|
332
333
|
refuse_queued_commands
|
|
333
334
|
end
|
|
334
|
-
#
|
|
335
|
-
#
|
|
335
|
+
# The offer went before the turn (drop_continue_offer); the rollback
|
|
336
|
+
# window closes.
|
|
336
337
|
@turn_flow.after_reminder_turn
|
|
337
338
|
@session.save(state_dir: @state_dir) unless stopped_on_disk?
|
|
338
339
|
true
|
|
@@ -476,8 +477,8 @@ module Samagotchi
|
|
|
476
477
|
@engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
|
|
477
478
|
end
|
|
478
479
|
|
|
479
|
-
# A prompt taken while a continue is offered
|
|
480
|
-
# the offer goes, the partial turn stays.
|
|
480
|
+
# A prompt (or a reminder turn) taken while a continue is offered
|
|
481
|
+
# replaces the answer (D2): the offer goes, the partial turn stays.
|
|
481
482
|
def drop_continue_offer(origin)
|
|
482
483
|
return unless @turn_flow.awaiting_continue?
|
|
483
484
|
|