samagotchi 0.4.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 +80 -1
- data/README.md +13 -2
- data/bin/chi +29 -39
- data/docs/cli.md +135 -73
- data/docs/configuration.md +15 -20
- data/docs/hooks.md +1 -1
- data/docs/memory.md +40 -0
- data/docs/plugins.md +50 -0
- data/docs/releasing.md +9 -6
- data/docs/sessions.md +3 -3
- 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 +1 -0
- data/lib/samagotchi/bridge.rb +16 -11
- data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
- data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -8
- data/lib/samagotchi/bundles/system/identity.md +5 -0
- data/lib/samagotchi/bundles/system/manifest.yml +5 -5
- 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 +16 -20
- data/lib/samagotchi/config.rb +40 -100
- data/lib/samagotchi/engine.rb +50 -354
- data/lib/samagotchi/kernel_loop.rb +33 -44
- data/lib/samagotchi/live_versions.rb +7 -1
- data/lib/samagotchi/llm/errors.rb +17 -0
- data/lib/samagotchi/llm/http.rb +4 -18
- data/lib/samagotchi/llm/openai_chat.rb +17 -0
- data/lib/samagotchi/model_profile.rb +4 -10
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +48 -4
- data/lib/samagotchi/self_report.rb +20 -2
- data/lib/samagotchi/send_command.rb +84 -6
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/session_manager.rb +18 -37
- 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 +120 -83
- data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
- data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
- data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
- data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
- 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 +95 -690
- data/lib/samagotchi/thinking.rb +11 -0
- data/lib/samagotchi/tool_activity.rb +52 -2
- data/lib/samagotchi/tool_runner.rb +3 -0
- 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/update_command.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +170 -35
- data/lib/samagotchi/web/lan.rb +99 -0
- data/lib/samagotchi/web/message_parts.rb +15 -11
- data/lib/samagotchi/web/public/activity.js +7 -0
- data/lib/samagotchi/web/public/app.js +99 -54
- data/lib/samagotchi/web/public/chat_view.js +5 -1
- data/lib/samagotchi/web/public/index.html +163 -17
- 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/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 +9 -5
- data/lib/samagotchi/web/public/turn_model.js +11 -3
- data/lib/samagotchi/web/public/turn_view.js +74 -19
- data/lib/samagotchi/web/qr.rb +40 -0
- data/lib/samagotchi/web/server.rb +101 -11
- data/lib/samagotchi/web/token.rb +97 -0
- metadata +27 -3
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
|
@@ -20,11 +20,15 @@ export function turnTimingText(number, durationMs, { running = false, canceled =
|
|
|
20
20
|
return `${turn}${running ? " running" : ""} · ${formatDuration(durationMs)}${canceled ? " · canceled" : ""}`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
//
|
|
23
|
+
// A cancel's reason as the terminal UIs name it too (Formatting::
|
|
24
|
+
// CANCEL_REASONS); anything else as it is.
|
|
25
|
+
const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook" };
|
|
26
|
+
|
|
27
|
+
// The line a canceled turn ends with: "✕ canceled (stopped)", live from
|
|
24
28
|
// turn_canceled's reason and after a re-render from the turn record's
|
|
25
29
|
// cancellation_reason (records saved before it existed: no reason).
|
|
26
30
|
export function cancelLineText(reason) {
|
|
27
|
-
return "\u2715 canceled" + (reason ? ` (${reason})` : "");
|
|
31
|
+
return "\u2715 canceled" + (reason ? ` (${CANCEL_REASONS[reason] || reason})` : "");
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
// A re-rendered turn's cancel line (a .bubble.cancel), or "" when the turn
|
|
@@ -82,13 +82,16 @@ export function isCommandLine(text) {
|
|
|
82
82
|
return /^[/!]/.test(String(text || "").trim());
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
// The terminal's own commands
|
|
86
|
-
// the page shows itself (they never go to the worker, which doesn't
|
|
87
|
-
// them), or null.
|
|
85
|
+
// The terminal's own commands (a button in the web, or none yet): the
|
|
86
|
+
// reply the page shows itself (they never go to the worker, which doesn't
|
|
87
|
+
// know them), or null.
|
|
88
88
|
const WEB_LOCAL_REPLIES = {
|
|
89
89
|
"/archive": "/archive: use the archive button in the session bar",
|
|
90
|
+
"/detach": "/detach: a terminal's command; close the tab to leave, the worker keeps running",
|
|
90
91
|
"/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
|
|
91
92
|
"/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
|
|
93
|
+
"/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now",
|
|
94
|
+
"/stats": "/stats: not in the web yet; each turn shows its time and the context use, and /stats in a terminal (chi --attach) has the totals",
|
|
92
95
|
};
|
|
93
96
|
|
|
94
97
|
export function webLocalReply(text) {
|
|
@@ -233,11 +236,12 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
|
|
|
233
236
|
closeText();
|
|
234
237
|
const call = { iteration: part.iteration, call_index: part.call_index, tool: part.tool };
|
|
235
238
|
if (part.label) call.label = part.label;
|
|
236
|
-
|
|
239
|
+
const title = part.title ? { title: part.title } : {};
|
|
240
|
+
events.push({ type: "tool_call_started", ...call, params: part.params, ...title });
|
|
237
241
|
if (part.status !== "running") {
|
|
238
242
|
const completed = {
|
|
239
243
|
type: "tool_call_completed", ...call, output: part.output, output_truncated: !!part.output_truncated,
|
|
240
|
-
activity: { status: part.status, params: part.params },
|
|
244
|
+
activity: { status: part.status, params: part.params, ...title },
|
|
241
245
|
};
|
|
242
246
|
if (part.diff) completed.diff = part.diff;
|
|
243
247
|
events.push(withImages(completed, part.images));
|
|
@@ -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 ? ", …" : ""}`;
|
|
@@ -23,7 +23,31 @@ import { createTicker } from "./thinking_ticker.js";
|
|
|
23
23
|
import * as model from "./turn_model.js";
|
|
24
24
|
import { createHold } from "./hold.js";
|
|
25
25
|
|
|
26
|
-
|
|
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
|
+
}) {
|
|
27
51
|
let turn = null;
|
|
28
52
|
let blockEl = null;
|
|
29
53
|
let blockSummaryEl = null;
|
|
@@ -36,7 +60,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
36
60
|
let rafPending = false;
|
|
37
61
|
// The promoted answer bubble while it is held at the box's height (the
|
|
38
62
|
// pop, D6).
|
|
39
|
-
const hold = createHold({ isNearBottom, follow
|
|
63
|
+
const hold = createHold({ isNearBottom, follow });
|
|
40
64
|
|
|
41
65
|
function removeHint() {
|
|
42
66
|
const hint = historyEl.querySelector(".hint");
|
|
@@ -58,7 +82,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
58
82
|
blockEl.appendChild(blockSummaryEl);
|
|
59
83
|
rememberToggle(blockSummaryEl, () => { blockToggled = true; });
|
|
60
84
|
updateBlockSummary();
|
|
61
|
-
|
|
85
|
+
blockHost(blockEl);
|
|
62
86
|
}
|
|
63
87
|
|
|
64
88
|
function updateBlockSummary() {
|
|
@@ -86,9 +110,10 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
86
110
|
feed: createSentenceFeed(), box: null };
|
|
87
111
|
rememberToggle(summary, () => { entry.toggled = true; });
|
|
88
112
|
genEls.set(gen, entry);
|
|
89
|
-
const
|
|
90
|
-
blockEl.
|
|
91
|
-
|
|
113
|
+
const stick = isNearBottom();
|
|
114
|
+
if (newestFirst) blockEl.insertBefore(el, blockSummaryEl.nextSibling);
|
|
115
|
+
else blockEl.appendChild(el);
|
|
116
|
+
if (stick) follow();
|
|
92
117
|
return entry;
|
|
93
118
|
}
|
|
94
119
|
|
|
@@ -135,6 +160,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
135
160
|
entry.thinkingLine.classList.remove("thinking-fade");
|
|
136
161
|
void entry.thinkingLine.offsetWidth;
|
|
137
162
|
entry.thinkingLine.classList.add("thinking-fade");
|
|
163
|
+
onThinkingLine(sentence);
|
|
138
164
|
}
|
|
139
165
|
|
|
140
166
|
// Feed the step's ticker from its accumulated thinking; the caller's timer
|
|
@@ -228,10 +254,11 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
228
254
|
rafPending = false;
|
|
229
255
|
// Runs every animation frame while streaming — must not yank a user who
|
|
230
256
|
// scrolled up to read; only follow when they were already near the bottom.
|
|
231
|
-
const
|
|
257
|
+
const stick = isNearBottom();
|
|
232
258
|
for (const gen of dirty) writeGen(gen, { follow: true });
|
|
233
259
|
dirty.clear();
|
|
234
|
-
if (follow)
|
|
260
|
+
if (stick) follow();
|
|
261
|
+
onChange();
|
|
235
262
|
}
|
|
236
263
|
|
|
237
264
|
function scheduleFlush(gen) {
|
|
@@ -291,6 +318,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
291
318
|
if (r.closed) closeGenEl(r.closed);
|
|
292
319
|
if (r.opened) createGenEl(r.gen);
|
|
293
320
|
if (r.closed || r.opened) updateBlockSummary();
|
|
321
|
+
onChange();
|
|
294
322
|
}
|
|
295
323
|
|
|
296
324
|
function toolEvent(r) {
|
|
@@ -311,14 +339,15 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
311
339
|
}
|
|
312
340
|
el.querySelector(".activity-output").style.display = "none";
|
|
313
341
|
entry.rows.set(r.row.key, el);
|
|
314
|
-
const
|
|
342
|
+
const stick = isNearBottom();
|
|
315
343
|
body.appendChild(el);
|
|
316
344
|
fillActivityRow(el, r.row, sessionId());
|
|
317
|
-
if (follow)
|
|
345
|
+
if (stick) follow();
|
|
318
346
|
} else {
|
|
319
347
|
fillActivityRow(el, r.row, sessionId());
|
|
320
348
|
}
|
|
321
349
|
updateBlockSummary();
|
|
350
|
+
onChange();
|
|
322
351
|
}
|
|
323
352
|
|
|
324
353
|
// A hook's line (hook_notice) as a row of the current step, above the row
|
|
@@ -333,9 +362,9 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
333
362
|
const el = document.createElement("div");
|
|
334
363
|
el.className = "hook-notice" + (data.level === "warn" ? " warn" : "");
|
|
335
364
|
el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
|
|
336
|
-
const
|
|
365
|
+
const stick = isNearBottom();
|
|
337
366
|
activityEl(r.gen).appendChild(el);
|
|
338
|
-
if (follow)
|
|
367
|
+
if (stick) follow();
|
|
339
368
|
return true;
|
|
340
369
|
}
|
|
341
370
|
|
|
@@ -368,9 +397,9 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
368
397
|
const entry = gen && genEls.get(gen);
|
|
369
398
|
if (!entry) return false;
|
|
370
399
|
if (!gen.text) entry.text.textContent = "";
|
|
371
|
-
const
|
|
400
|
+
const stick = isNearBottom();
|
|
372
401
|
activityEl(gen).appendChild(el);
|
|
373
|
-
if (follow)
|
|
402
|
+
if (stick) follow();
|
|
374
403
|
return true;
|
|
375
404
|
}
|
|
376
405
|
|
|
@@ -384,7 +413,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
384
413
|
bubble.className = "bubble output";
|
|
385
414
|
if (!gen.text) bubble.textContent = "";
|
|
386
415
|
// A hidden tab gets no pop: nobody sees it, and its timers are throttled.
|
|
387
|
-
const holding = heldHeight > 0 && !!gen.text && !document.hidden;
|
|
416
|
+
const holding = useHold && heldHeight > 0 && !!gen.text && !document.hidden;
|
|
388
417
|
let holdHeight = 0;
|
|
389
418
|
if (holding) {
|
|
390
419
|
// Bubbles are border-box: the box's height plus the bubble's own
|
|
@@ -425,12 +454,11 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
425
454
|
}
|
|
426
455
|
pieces.push(...cards);
|
|
427
456
|
if (gen.text) pieces.push(bubble);
|
|
428
|
-
|
|
457
|
+
placeAnswer(pieces, { plain: true, blockEl });
|
|
429
458
|
blockEl = null;
|
|
430
459
|
blockSummaryEl = null;
|
|
431
460
|
} else {
|
|
432
|
-
|
|
433
|
-
if (gen.text) appendToHistory(bubble);
|
|
461
|
+
placeAnswer(gen.text ? [...cards, bubble] : cards, { plain: false, blockEl });
|
|
434
462
|
// The last step, left with its thinking only, reads "thinking" once.
|
|
435
463
|
if (!gen.tools.length && gen.thinking) unwrapThinking(entry);
|
|
436
464
|
}
|
|
@@ -468,6 +496,7 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
468
496
|
}
|
|
469
497
|
dirty.clear();
|
|
470
498
|
rafPending = false;
|
|
499
|
+
onChange();
|
|
471
500
|
return kept;
|
|
472
501
|
}
|
|
473
502
|
|
|
@@ -545,6 +574,30 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
545
574
|
return !!entry && (entry.text.contains(el) || !!entry.thinking?.contains(el));
|
|
546
575
|
},
|
|
547
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));
|
|
548
601
|
};
|
|
549
602
|
}
|
|
550
603
|
|
|
@@ -555,7 +608,9 @@ export function createTurnView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
555
608
|
// parts; without them the row has the record's name, status and duration
|
|
556
609
|
// only. +thumbs(images)+ draws the images, as the live row does.
|
|
557
610
|
function reloadRowHtml(r, thumbs) {
|
|
558
|
-
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>` : "";
|
|
559
614
|
const duration = formatDuration(r.duration_ms);
|
|
560
615
|
const full = String(r.output || "").replace(/^\[[^\]]*\]\s*/, "");
|
|
561
616
|
const output = full
|
|
@@ -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
|