samagotchi 0.130.0 → 0.131.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 +62 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +15 -4
- data/docs/configuration.md +4 -1
- data/docs/desktop.md +28 -9
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/participants.md +36 -9
- data/docs/internals/prompt-caching.md +5 -2
- data/docs/sessions.md +4 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cache_confirm.rb +29 -9
- data/lib/samagotchi/desktop/macos.rb +99 -19
- data/lib/samagotchi/desktop_command.rb +7 -2
- data/lib/samagotchi/engine.rb +31 -2
- data/lib/samagotchi/guardrails/rules.rb +2 -2
- data/lib/samagotchi/guardrails/targets.rb +13 -0
- data/lib/samagotchi/level_setting_command.rb +3 -2
- data/lib/samagotchi/session.rb +49 -4
- data/lib/samagotchi/session_commands.rb +4 -4
- data/lib/samagotchi/toolkit_command.rb +2 -2
- data/lib/samagotchi/tools/execute.rb +5 -2
- data/lib/samagotchi/tools/task_runtime.rb +4 -2
- data/lib/samagotchi/update_command.rb +9 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +47 -8
- data/lib/samagotchi/web/participant_review.rb +9 -2
- data/lib/samagotchi/web/participant_view.rb +22 -6
- data/lib/samagotchi/web/proxy_gate.rb +6 -0
- data/lib/samagotchi/web/public/annotate_bar.js +6 -1
- data/lib/samagotchi/web/public/app.js +14 -5
- data/lib/samagotchi/web/public/participant.js +24 -6
- data/lib/samagotchi/web/public/verdict_card.js +48 -4
- data/lib/samagotchi/web/public/verdict_fresh.js +8 -4
- data/lib/samagotchi/web/public/verify_target.js +73 -0
- data/lib/samagotchi/web/token.rb +20 -1
- data/lib/samagotchi/worker.rb +7 -0
- metadata +2 -1
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -55,6 +55,7 @@ require_relative "../review_start"
|
|
|
55
55
|
require_relative "../parent_answers_command"
|
|
56
56
|
require_relative "../guardrails/parent_policy"
|
|
57
57
|
require_relative "markdown_renderer"
|
|
58
|
+
require_relative "token"
|
|
58
59
|
require_relative "capabilities"
|
|
59
60
|
require_relative "editor"
|
|
60
61
|
require_relative "../message_parts"
|
|
@@ -431,6 +432,10 @@ module Samagotchi
|
|
|
431
432
|
end
|
|
432
433
|
|
|
433
434
|
TOKEN_COOKIE = "chi_token"
|
|
435
|
+
# The owner's cookie holds the token, but on the LAN a loopback
|
|
436
|
+
# peer's holds Token.loopback_cookie (#owner_cookie_value): a cookie
|
|
437
|
+
# for 127.0.0.1 goes to every server on any of its ports (a dev
|
|
438
|
+
# server that logs headers), and that value works nowhere else.
|
|
434
439
|
# 400 days, the longest a browser keeps a cookie (Chrome's cap). No
|
|
435
440
|
# Secure: the LAN page is plain http. Lax, not Strict: a token link
|
|
436
441
|
# opened from another site (a web messenger) makes its 303 chain
|
|
@@ -473,10 +478,10 @@ module Samagotchi
|
|
|
473
478
|
principal = principal_for(req)
|
|
474
479
|
return [nil, refused_token(req)] if principal.nil?
|
|
475
480
|
|
|
476
|
-
# The cookie gets the
|
|
477
|
-
#
|
|
481
|
+
# The cookie gets the participant's grant's token, or the owner's
|
|
482
|
+
# cookie value (the loopback one on the LAN's 127.0.0.1).
|
|
478
483
|
if token_trade?(req)
|
|
479
|
-
token = req.env[GRANT_TOKEN_ENV] ||
|
|
484
|
+
token = req.env[GRANT_TOKEN_ENV] || owner_cookie_value(req)
|
|
480
485
|
return [nil, token_redirect(req, token)] unless fragment_trade?(req)
|
|
481
486
|
return [nil, form_trade_redirect(req, token)] if form_post?(req)
|
|
482
487
|
|
|
@@ -492,7 +497,9 @@ module Samagotchi
|
|
|
492
497
|
# one, looked up only when no owner token was given), wherever the
|
|
493
498
|
# request comes from, this machine's loopback too; with neither, the
|
|
494
499
|
# owner only on a chi web that asks no token (loopback only: no LAN
|
|
495
|
-
# mode, no web.public_url), else nil.
|
|
500
|
+
# mode, no web.public_url), else nil. The owner's cookie on the LAN's
|
|
501
|
+
# loopback is the loopback value (#loopback_cookie: it stands for the
|
|
502
|
+
# token in +given+). An ended or unknown grant's
|
|
496
503
|
# token is nil anywhere: it never falls back to the owner.
|
|
497
504
|
# @return [Principal, nil]
|
|
498
505
|
# With web.public_url (ProxyGate) every request needs a token, and a
|
|
@@ -512,12 +519,39 @@ module Samagotchi
|
|
|
512
519
|
# through the proxy (curl, chi's own callers) presents the Bearer
|
|
513
520
|
# header only: no cookie, and no trade that would set one. A cookie for
|
|
514
521
|
# 127.0.0.1 goes to every server on any of its ports. A LAN peer
|
|
515
|
-
# (web.host lan) keeps the LAN's token link and cookie
|
|
522
|
+
# (web.host lan) keeps the LAN's token link and cookie; a loopback
|
|
523
|
+
# peer of a LAN chi web reads its cookie through #loopback_cookie.
|
|
516
524
|
def presented_tokens(req)
|
|
517
525
|
return [traded_token(req)] if token_trade?(req)
|
|
518
526
|
return [bearer_token(req)] if direct_public?(req)
|
|
519
527
|
|
|
520
|
-
|
|
528
|
+
cookie = req.cookies[token_cookie_name(req)]
|
|
529
|
+
[bearer_token(req), loopback_cookie_peer?(req) ? loopback_cookie(cookie) : cookie]
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# A loopback peer of a LAN chi web, whose owner cookie holds the
|
|
533
|
+
# loopback value. With web.public_url, #direct_public? takes the
|
|
534
|
+
# Bearer only. A request with a proxy's trace (one the user forwards
|
|
535
|
+
# to loopback by hand) is the LAN's: a leaked loopback value must not
|
|
536
|
+
# work from off this machine through it.
|
|
537
|
+
def loopback_cookie_peer?(req)
|
|
538
|
+
!@lan.nil? && @public_url.nil? && LOOPBACK_PEERS.include?(req.get_header("REMOTE_ADDR").to_s) &&
|
|
539
|
+
!proxy_trace?(req.env)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# What a trade sets as the owner's cookie.
|
|
543
|
+
def owner_cookie_value(req)
|
|
544
|
+
loopback_cookie_peer?(req) ? Token.loopback_cookie(current_token) : current_token
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# A loopback peer's cookie: the loopback value stands for the owner's
|
|
548
|
+
# token, a grant's chp_ token is itself, and anything else, the token
|
|
549
|
+
# itself included (a 0.130.0 cookie, #refused_token clears it), is
|
|
550
|
+
# nothing.
|
|
551
|
+
def loopback_cookie(value)
|
|
552
|
+
return current_token if token_match?(value, Token.loopback_cookie(current_token))
|
|
553
|
+
|
|
554
|
+
Grants.token?(value) ? value : nil
|
|
521
555
|
end
|
|
522
556
|
|
|
523
557
|
def direct_public?(req)
|
|
@@ -622,10 +656,15 @@ module Samagotchi
|
|
|
622
656
|
# The 401 for a request #principal_for refused: an ended grant's, else
|
|
623
657
|
# unauthorized. On a peer the gate doesn't ask, a grant's cookie that
|
|
624
658
|
# no longer works is cleared too, so the browser it was left in (the
|
|
625
|
-
# owner's, who opened a participant link) is the owner's again.
|
|
659
|
+
# owner's, who opened a participant link) is the owner's again. A
|
|
660
|
+
# LAN chi web's loopback peer whose cookie is the token itself (set
|
|
661
|
+
# before the loopback value) has it cleared, so it stops riding to
|
|
662
|
+
# the other local ports.
|
|
626
663
|
def refused_token(req)
|
|
627
664
|
response = grant_bound_response(req) || grant_ended_response(req) || unauthorized(req)
|
|
628
|
-
|
|
665
|
+
cookie = req.cookies[token_cookie_name(req)]
|
|
666
|
+
stale = loopback_cookie_peer?(req) && token_match?(cookie, current_token)
|
|
667
|
+
return response unless stale || (!token_required?(req) && Grants.token?(cookie))
|
|
629
668
|
|
|
630
669
|
status, headers, body = response
|
|
631
670
|
[status, headers.merge("Set-Cookie" => cleared_token_cookie(req)), body]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "participant_view"
|
|
4
|
+
require_relative "token"
|
|
4
5
|
require_relative "participant_stream_filter"
|
|
5
6
|
require_relative "participant_exposure"
|
|
6
7
|
require_relative "../session_metrics"
|
|
@@ -39,10 +40,16 @@ module Samagotchi
|
|
|
39
40
|
|
|
40
41
|
# Who reads: the participant (their author), or the owner through the
|
|
41
42
|
# participant view (nobody's own). The session's folder places tool
|
|
42
|
-
# paths; the owner's token is never shown
|
|
43
|
+
# paths; the owner's token is never shown, nor (LAN) its loopback
|
|
44
|
+
# cookie value.
|
|
43
45
|
def participant_viewer(req, session)
|
|
44
46
|
author = principal(req).owner? ? nil : principal(req).grant.author
|
|
45
|
-
ParticipantView::Viewer.new(author: author, cwd: session.working_directory, secrets:
|
|
47
|
+
ParticipantView::Viewer.new(author: author, cwd: session.working_directory, secrets: owner_secrets)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def owner_secrets
|
|
51
|
+
token = (@lan || @public_url) && current_token
|
|
52
|
+
[token, @lan && Token.loopback_cookie(token)].compact
|
|
46
53
|
end
|
|
47
54
|
|
|
48
55
|
# GET /api/sessions/:id/review: the participant page's data, always
|
|
@@ -211,7 +211,8 @@ module Samagotchi
|
|
|
211
211
|
# @return [Array<Hash>] {role: "user", content, turn_id, author,
|
|
212
212
|
# author_name, mine, automatic, images, at} and {role: "assistant",
|
|
213
213
|
# content, tools: [{tool, path}], at}; no notes, steers, automatic
|
|
214
|
-
# input or empty-answer markers
|
|
214
|
+
# input or empty-answer markers, but a wake turn's start as an
|
|
215
|
+
# automatic turn (#wake_start). html is added by the caller, from
|
|
215
216
|
# this (redacted) content.
|
|
216
217
|
def messages(shown, viewer, automatic: [])
|
|
217
218
|
Array(shown).filter_map do |message|
|
|
@@ -220,6 +221,7 @@ module Samagotchi
|
|
|
220
221
|
out = case field(message, :role).to_s
|
|
221
222
|
when "user" then user_message(message, viewer, automatic)
|
|
222
223
|
when "assistant" then assistant_message(message, viewer)
|
|
224
|
+
when "note" then wake_start(message)
|
|
223
225
|
end
|
|
224
226
|
out && Redact.deep(out, viewer)
|
|
225
227
|
end
|
|
@@ -228,15 +230,15 @@ module Samagotchi
|
|
|
228
230
|
# A user message a participant sees: a turn's prompt (it has the
|
|
229
231
|
# turn's id) or a person's line merged into a turn. Every other user
|
|
230
232
|
# message is dropped: the owner's `!` line and its output, a
|
|
231
|
-
# delegate's report (a wake turn's input
|
|
232
|
-
#
|
|
233
|
-
#
|
|
233
|
+
# delegate's report (a wake turn's input: only its turn's start is
|
|
234
|
+
# kept, #wake_start), the notes chi adds (a Continue the owner
|
|
235
|
+
# declined). Any other message from a source that isn't a person's is
|
|
236
|
+
# dropped whatever it is.
|
|
234
237
|
def user_message(message, viewer, automatic = [])
|
|
235
238
|
source = field(message, :source).to_s
|
|
236
|
-
return nil unless PERSON_SOURCES.include?(source)
|
|
237
|
-
|
|
238
239
|
turn_id = field(message, :turn_id)
|
|
239
240
|
merged = field(message, :merged)
|
|
241
|
+
return (turn_id && !merged ? wake_start(message) : nil) unless PERSON_SOURCES.include?(source)
|
|
240
242
|
return nil unless turn_id || merged
|
|
241
243
|
|
|
242
244
|
out = { "role" => "user", **time_field(message) }
|
|
@@ -254,6 +256,20 @@ module Samagotchi
|
|
|
254
256
|
out
|
|
255
257
|
end
|
|
256
258
|
|
|
259
|
+
# A wake turn's start (its prompt a delegate's report or a context
|
|
260
|
+
# note, MessageDisplay's turn_start note): the turn's boundary only,
|
|
261
|
+
# as an automatic turn with no text, so its answer is never read as
|
|
262
|
+
# one more reply of the turn before (Verify, verdict cards, claim
|
|
263
|
+
# links group by these). Nothing of the report or the note. nil for
|
|
264
|
+
# a message that starts no turn.
|
|
265
|
+
def wake_start(message)
|
|
266
|
+
turn_id = field(message, :turn_id)
|
|
267
|
+
return nil if turn_id.to_s.empty?
|
|
268
|
+
return nil if field(message, :role).to_s == "note" && field(message, :turn_start) != true
|
|
269
|
+
|
|
270
|
+
{ "role" => "user", **time_field(message), "content" => "", "automatic" => true, "turn_id" => turn_id.to_s }
|
|
271
|
+
end
|
|
272
|
+
|
|
257
273
|
def assistant_message(message, viewer)
|
|
258
274
|
parts = field(message, :parts)
|
|
259
275
|
tools = parts.is_a?(Hash) ? Array(field(parts, :tools)) : []
|
|
@@ -74,6 +74,12 @@ module Samagotchi
|
|
|
74
74
|
return true if @public_url.host?(env["HTTP_HOST"])
|
|
75
75
|
return true if @public_url.identity_env && env.key?(@public_url.identity_env)
|
|
76
76
|
|
|
77
|
+
proxy_trace?(env)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Whether a request carries a proxy's trace (ProxyTrace: X-Forwarded-*,
|
|
81
|
+
# Forwarded, X-Real-IP, a known identity header).
|
|
82
|
+
def proxy_trace?(env)
|
|
77
83
|
names = env[RAW_NAMES_ENV] || env.keys.filter_map { |key| ProxyTrace.env_name(key) }
|
|
78
84
|
names.any? { |name| ProxyTrace.header?(name) }
|
|
79
85
|
end
|
|
@@ -33,6 +33,9 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
|
|
|
33
33
|
// @param onQuote (text) runs with the quote (and a preset's note) to append
|
|
34
34
|
// to the composer; the selection is cleared and the bar hidden first
|
|
35
35
|
// @param disabledPreset (preset) → a reason to disable it, else null
|
|
36
|
+
// @param offeredPreset (preset, source, range) → whether the selection
|
|
37
|
+
// offers it (default: every preset); one not offered is hidden, not
|
|
38
|
+
// disabled (Verify outside a turn's final answer: verify_target.js)
|
|
36
39
|
// @param actions () => the open session's annotation actions
|
|
37
40
|
// (annotate_actions.js annotationActions), read on every selection
|
|
38
41
|
// @param running () => whether a turn runs (a send pill may wait for it)
|
|
@@ -58,6 +61,7 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
|
|
|
58
61
|
// next frame (call it on scroll)
|
|
59
62
|
export function createAnnotateBar({
|
|
60
63
|
presets, canAnnotate, roots, isLive, onQuote, onPreset = () => {}, disabledPreset = () => null,
|
|
64
|
+
offeredPreset = () => true,
|
|
61
65
|
actions = () => [], running = () => false, onAction = () => {},
|
|
62
66
|
dock = () => null, source: sourceOf = annotationSource, quote = quoteBlock, link = () => null, onLink = () => {},
|
|
63
67
|
doc = globalThis.document, win = globalThis.window,
|
|
@@ -85,6 +89,7 @@ export function createAnnotateBar({
|
|
|
85
89
|
const presetButtons = [...bar.children].slice(1, presets.length + 1);
|
|
86
90
|
function markPresets() {
|
|
87
91
|
presetButtons.forEach((btn, i) => {
|
|
92
|
+
btn.hidden = !!pendingSource && !offeredPreset(presets[i], pendingSource, pendingRange);
|
|
88
93
|
const reason = disabledPreset(presets[i]);
|
|
89
94
|
btn.disabled = !!reason;
|
|
90
95
|
btn.title = reason || presetParts(presets[i]).text;
|
|
@@ -259,7 +264,7 @@ export function createAnnotateBar({
|
|
|
259
264
|
return;
|
|
260
265
|
}
|
|
261
266
|
const preset = btn.dataset.index === undefined ? null : presets[Number(btn.dataset.index)];
|
|
262
|
-
if (btn.disabled || (preset && disabledPreset(preset))) return;
|
|
267
|
+
if (btn.disabled || btn.hidden || (preset && disabledPreset(preset))) return;
|
|
263
268
|
const text = preset ? presetNote(pendingQuote, presetParts(preset).text) : pendingQuote;
|
|
264
269
|
const fields = { ...pendingFields, source: pendingSource, range: pendingRange };
|
|
265
270
|
win.getSelection()?.removeAllRanges();
|
|
@@ -65,6 +65,7 @@ import { authorBadge, nameUnnamedBadges } from "./participant_names.js";
|
|
|
65
65
|
import { parsePresets } from "./annotate_presets.js";
|
|
66
66
|
import { createAnnotateBar } from "./annotate_bar.js";
|
|
67
67
|
import { VERIFY_PRESET, VERIFY_UNAVAILABLE, verifyWorkerUnavailable, withVerify } from "./verify_preset.js";
|
|
68
|
+
import { OWNER_FINAL, verifyTurnOf } from "./verify_target.js";
|
|
68
69
|
import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
|
|
69
70
|
import { copyText, installCopy } from "./copy.js";
|
|
70
71
|
import { createQuestionCards } from "./question_cards.js";
|
|
@@ -1351,6 +1352,8 @@ function placeVerdict(record) {
|
|
|
1351
1352
|
placeVerdictWithFresh(historyEl, record, {
|
|
1352
1353
|
who: badge ? badge.name : "You",
|
|
1353
1354
|
author: record.by || null,
|
|
1355
|
+
roots: CLAIM_ROOTS,
|
|
1356
|
+
opts: claimOpts(),
|
|
1354
1357
|
onMissingCheck: (missing) => {
|
|
1355
1358
|
const hash = ownerCheckHash(allSessions, selected, missing.check_turn_id);
|
|
1356
1359
|
if (hash) location.hash = hash;
|
|
@@ -1959,8 +1962,9 @@ const updateSheet = createUpdateSheet({
|
|
|
1959
1962
|
function showToast(text, action = null, options = {}) { toasts.show(text, action, options); }
|
|
1960
1963
|
|
|
1961
1964
|
// Annotate (annotate_bar.js): a selection in the conversation offers
|
|
1962
|
-
// "Annotate" and the presets (then the built-in Verify: verify_preset.js
|
|
1963
|
-
//
|
|
1965
|
+
// "Annotate" and the presets (then the built-in Verify: verify_preset.js,
|
|
1966
|
+
// only in an ended turn's final answer: verify_target.js), which append a
|
|
1967
|
+
// quote to the composer, the session's plugins' pills
|
|
1964
1968
|
// (chi.annotation, annotate_actions.js), which fill the composer with their
|
|
1965
1969
|
// command line and the quote, or send it at once, and Copy link in a turn
|
|
1966
1970
|
// (claimLinkFor).
|
|
@@ -1978,6 +1982,7 @@ annotateBar = createAnnotateBar({
|
|
|
1978
1982
|
presets: withVerify(parsePresets(document.body.dataset.annotatePresets)),
|
|
1979
1983
|
disabledPreset: (preset) => preset === VERIFY_PRESET
|
|
1980
1984
|
&& verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp) ? VERIFY_UNAVAILABLE : null,
|
|
1985
|
+
offeredPreset: (preset, source, range) => preset !== VERIFY_PRESET || !!verifyAnchorFor({ source, range }),
|
|
1981
1986
|
canAnnotate: () => !!selected && current.owner !== "tui",
|
|
1982
1987
|
roots: turnRoots,
|
|
1983
1988
|
isLive: (root) => liveTurn.running && turnDom.containsLive(root),
|
|
@@ -1997,10 +2002,14 @@ annotateBar = createAnnotateBar({
|
|
|
1997
2002
|
dock: () => $("#dock"),
|
|
1998
2003
|
});
|
|
1999
2004
|
|
|
2005
|
+
// Verify's claim: a selection in an ended turn's final answer (the only
|
|
2006
|
+
// text Engine#verified_anchor takes; verify_target.js), else null and no
|
|
2007
|
+
// Verify on the bar.
|
|
2000
2008
|
function verifyAnchorFor({ source, range }) {
|
|
2001
|
-
if (
|
|
2002
|
-
const
|
|
2003
|
-
const
|
|
2009
|
+
if (source?.kind !== "answer" || !range) return null;
|
|
2010
|
+
const running = liveTurn.running ? { turnId: liveTurn.timing.turnId || null } : null;
|
|
2011
|
+
const turnId = verifyTurnOf(historyEl, source.root, OWNER_FINAL, { running });
|
|
2012
|
+
const quote = turnId ? selectionClaim(source.root, range, claimOpts()).q : "";
|
|
2004
2013
|
return turnId && quote ? { turn_id: turnId, quote } : null;
|
|
2005
2014
|
}
|
|
2006
2015
|
historyEl.addEventListener("scroll", annotateBar.schedule);
|
|
@@ -23,6 +23,7 @@ import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_f
|
|
|
23
23
|
import { createAnnotateBar } from "./annotate_bar.js";
|
|
24
24
|
import { parsePresets } from "./annotate_presets.js";
|
|
25
25
|
import { VERIFY_PRESET, VERIFY_UNAVAILABLE, withVerify } from "./verify_preset.js";
|
|
26
|
+
import { PARTICIPANT_FINAL, verifyTurnOf } from "./verify_target.js";
|
|
26
27
|
import { appendQuote } from "./annotations.js";
|
|
27
28
|
import { messageAuthorData, participantQuote, participantSource } from "./participant_annotate.js";
|
|
28
29
|
import { claimFromHash, selectionClaim, showClaim } from "./claim_link.js";
|
|
@@ -76,6 +77,8 @@ let drawnMessages = [];
|
|
|
76
77
|
// A claim link's turn and quote (claim_link.js: #s=…&t=…&q=…), shown once
|
|
77
78
|
// the thread is drawn; a later hash change shows another.
|
|
78
79
|
let pendingClaim = claimFromHash(globalThis.location?.hash);
|
|
80
|
+
// Where a claim's text can be on this page (a claim link, a verdict's card).
|
|
81
|
+
const CLAIM_ROOTS = ".p-msg > .p-bubble";
|
|
79
82
|
let claimNoteTimer = null;
|
|
80
83
|
|
|
81
84
|
function el(tag, cls, text) {
|
|
@@ -259,7 +262,7 @@ function showPendingClaim() {
|
|
|
259
262
|
return showClaimNote(`This link is for another session; this browser holds your link for ${title}.`);
|
|
260
263
|
}
|
|
261
264
|
setView("thread");
|
|
262
|
-
if (showClaim($("thread"), claim, { roots:
|
|
265
|
+
if (showClaim($("thread"), claim, { roots: CLAIM_ROOTS })) return;
|
|
263
266
|
$("main").scrollTop = 0;
|
|
264
267
|
showClaimNote("The linked turn isn't in this session.");
|
|
265
268
|
}
|
|
@@ -330,6 +333,8 @@ function setView(view) {
|
|
|
330
333
|
$("segThread").setAttribute("aria-selected", String(shown === "thread"));
|
|
331
334
|
}
|
|
332
335
|
|
|
336
|
+
const AUTOMATIC_TURN = "An automatic turn";
|
|
337
|
+
|
|
333
338
|
function messageNode(message) {
|
|
334
339
|
const user = message.role === "user";
|
|
335
340
|
const node = el("div", `p-msg ${user ? "user" : "reply"}${message.mine ? " mine" : ""}`);
|
|
@@ -346,7 +351,10 @@ function messageNode(message) {
|
|
|
346
351
|
if (message.merged) who.append(el("span", "p-tag", "added mid-turn"));
|
|
347
352
|
if (isParticipantAuthor(message.author)) who.append(el("span", "p-tag", modeCopy(message.participant_mode).tag));
|
|
348
353
|
node.append(who);
|
|
349
|
-
|
|
354
|
+
// An automatic turn (a task's, a wake turn's: a delegate report or a
|
|
355
|
+
// context note started it) opens its turn without its prompt.
|
|
356
|
+
const content = message.automatic && !message.content ? AUTOMATIC_TURN : message.content;
|
|
357
|
+
const text = content + (message.images ? `\n[${message.images} image${message.images > 1 ? "s" : ""}]` : "");
|
|
350
358
|
node.append(el("div", "p-bubble", text));
|
|
351
359
|
return node;
|
|
352
360
|
}
|
|
@@ -423,6 +431,7 @@ function renderThread() {
|
|
|
423
431
|
placeVerdictWithFresh(thread, record, {
|
|
424
432
|
who,
|
|
425
433
|
author,
|
|
434
|
+
roots: CLAIM_ROOTS,
|
|
426
435
|
onMissingCheck: () => showClaimNote(CHECK_NOT_ON_PAGE),
|
|
427
436
|
}, verifyFreshState());
|
|
428
437
|
}
|
|
@@ -477,7 +486,7 @@ function currentTurnNodes() {
|
|
|
477
486
|
const turn = data.current_turn;
|
|
478
487
|
const nodes = [];
|
|
479
488
|
if (turn.prompt !== undefined || turn.automatic) {
|
|
480
|
-
nodes.push(messageNode({ role: "user", content: turn.automatic ?
|
|
489
|
+
nodes.push(messageNode({ role: "user", content: turn.automatic ? AUTOMATIC_TURN : turn.prompt, author: turn.author,
|
|
481
490
|
author_name: turn.author_name, mine: turn.mine, automatic: turn.automatic, at: turn.at, turn_id: turn.turn_id }));
|
|
482
491
|
}
|
|
483
492
|
const reply = el("div", "p-msg reply live");
|
|
@@ -653,7 +662,8 @@ for (const button of $("viewSeg").querySelectorAll("button")) {
|
|
|
653
662
|
|
|
654
663
|
// Annotate (annotate_bar.js, participant_annotate.js): a selection in the
|
|
655
664
|
// Plan or the thread offers "Annotate" and the presets
|
|
656
|
-
// (web.annotate_presets, then the built-in Verify: verify_preset.js
|
|
665
|
+
// (web.annotate_presets, then the built-in Verify: verify_preset.js, only
|
|
666
|
+
// in an ended turn's final reply: verify_target.js), which
|
|
657
667
|
// append a quote to the comment box; nothing is sent. No plugin pills: a
|
|
658
668
|
// review turn refuses `/` lines. Not in the owner's participant view, nor
|
|
659
669
|
// while this participant can't comment.
|
|
@@ -661,6 +671,7 @@ const annotateBar = createAnnotateBar({
|
|
|
661
671
|
presets: withVerify(parsePresets(body.annotatePresets)),
|
|
662
672
|
disabledPreset: (preset) => preset === VERIFY_PRESET
|
|
663
673
|
&& data?.session?.verify_unavailable === true ? VERIFY_UNAVAILABLE : null,
|
|
674
|
+
offeredPreset: (preset, source, range) => preset !== VERIFY_PRESET || !!verifyAnchorFor({ source, range }),
|
|
664
675
|
canAnnotate: () => !ownerView && !ended && !!data?.viewer?.can_comment,
|
|
665
676
|
roots: () => [$("planDoc"), $("thread")],
|
|
666
677
|
isLive: (root) => !!root.closest?.(".p-msg.live"),
|
|
@@ -671,9 +682,16 @@ const annotateBar = createAnnotateBar({
|
|
|
671
682
|
dock: () => $("dock"),
|
|
672
683
|
});
|
|
673
684
|
|
|
685
|
+
// Verify's claim: a selection in an ended turn's final reply (the last
|
|
686
|
+
// reply bubble after the turn's opening, as the owner's page and
|
|
687
|
+
// placeVerdict count turns: verify_target.js), the only text
|
|
688
|
+
// Engine#verified_anchor takes; else null and no Verify on the bar. Not an
|
|
689
|
+
// earlier reply, a line merged mid-turn, a prompt, the running turn or the
|
|
690
|
+
// Plan.
|
|
674
691
|
function verifyAnchorFor({ source, range }) {
|
|
675
|
-
|
|
676
|
-
const
|
|
692
|
+
if (source?.kind !== "reply" || !range) return null;
|
|
693
|
+
const running = data?.current_turn ? { turnId: data.current_turn.turn_id || null } : null;
|
|
694
|
+
const turnId = verifyTurnOf($("thread"), source.root, PARTICIPANT_FINAL, { running });
|
|
677
695
|
if (!turnId) return null;
|
|
678
696
|
const quote = selectionClaim(source.root, range).q;
|
|
679
697
|
return quote ? { turn_id: turnId, quote } : null;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ownerClaimHash } from "./claim_link.js";
|
|
1
|
+
import { findClaim, ownerClaimHash } from "./claim_link.js";
|
|
2
2
|
|
|
3
3
|
const TYPES = Object.freeze({
|
|
4
4
|
confirmed: { icon: "✅", label: "confirmed" },
|
|
@@ -17,7 +17,14 @@ export function ownerCheckHash(sessions, currentSessionId, checkTurnId) {
|
|
|
17
17
|
return session ? ownerClaimHash(session.id, { turnId: checkTurnId }) : null;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// A verdict's card on its source turn: after the bubble that holds the
|
|
21
|
+
// quote (+roots+, the page's claim roots selector; +opts+, how it reads the
|
|
22
|
+
// page's text, claim_link.js), the answer's first; a quote not found (a
|
|
23
|
+
// bubble not drawn yet): at the turn's end. Cards on one bubble keep their
|
|
24
|
+
// order; a card already on the page is returned as it is.
|
|
25
|
+
export function placeVerdict(root, record, {
|
|
26
|
+
who = "Owner", author = null, onMissingCheck = null, roots = null, opts = {},
|
|
27
|
+
} = {}) {
|
|
21
28
|
const anchor = record?.anchor;
|
|
22
29
|
const type = TYPES[record?.verdict];
|
|
23
30
|
if (!root || !type || typeof anchor?.turn_id !== "string" || typeof anchor?.quote !== "string"
|
|
@@ -67,11 +74,48 @@ export function placeVerdict(root, record, { who = "Owner", author = null, onMis
|
|
|
67
74
|
});
|
|
68
75
|
card.append(heading, quote, reason, link);
|
|
69
76
|
|
|
70
|
-
|
|
71
|
-
(previous || source).after(card);
|
|
77
|
+
afterCards(quotedElement(source, anchor.quote, roots, opts) || turnEnd(source)).after(card);
|
|
72
78
|
return card;
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
// The element at +source+'s level that holds the quote: of the turn's
|
|
82
|
+
// roots after its prompt, the last match first (the answer the Engine
|
|
83
|
+
// checked the quote against comes after the turn's steps).
|
|
84
|
+
function quotedElement(source, quote, selector, opts) {
|
|
85
|
+
if (!selector || !source.parentElement) return null;
|
|
86
|
+
const level = source.parentElement;
|
|
87
|
+
const candidates = turnElements(source).flatMap((element) =>
|
|
88
|
+
[...(element.matches(selector) ? [element] : []), ...element.querySelectorAll(selector)]).reverse();
|
|
89
|
+
const hit = findClaim(candidates.map((candidate) => candidate.textContent), { q: quote }, opts);
|
|
90
|
+
if (!hit) return null;
|
|
91
|
+
let element = candidates[hit.at];
|
|
92
|
+
while (element && element.parentElement !== level) element = element.parentElement;
|
|
93
|
+
return element || null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// +source+'s turn: its siblings after it up to the next turn's opening
|
|
97
|
+
// (or a gap divider before it).
|
|
98
|
+
function turnElements(source) {
|
|
99
|
+
const elements = [];
|
|
100
|
+
for (let next = source.nextElementSibling; next; next = next.nextElementSibling) {
|
|
101
|
+
if (next.matches("[data-turn-id]") || next.querySelector("[data-turn-id]")) break;
|
|
102
|
+
elements.push(next);
|
|
103
|
+
}
|
|
104
|
+
while (elements.at(-1)?.classList.contains("time-gap")) elements.pop();
|
|
105
|
+
return elements;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function turnEnd(source) {
|
|
109
|
+
return turnElements(source).at(-1) || source;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// +element+, or the last of the verdict cards right after it.
|
|
113
|
+
function afterCards(element) {
|
|
114
|
+
let last = element;
|
|
115
|
+
while (last.nextElementSibling?.classList.contains("verify-verdict")) last = last.nextElementSibling;
|
|
116
|
+
return last;
|
|
117
|
+
}
|
|
118
|
+
|
|
75
119
|
export function nameVerdictCards(root, nameFor) {
|
|
76
120
|
for (const card of root?.querySelectorAll?.(".verify-verdict[data-author]") || []) {
|
|
77
121
|
const name = nameFor(card.dataset.author);
|
|
@@ -51,7 +51,8 @@ export function redrawFresh(root, fresh = {}) {
|
|
|
51
51
|
|
|
52
52
|
// The card's Verify fresh part, drawn again from +fresh+: the fresh
|
|
53
53
|
// verdicts, the running check, else the button (an unclear verdict, and a
|
|
54
|
-
// viewer who could run a turn now
|
|
54
|
+
// viewer who could run a turn now; "Verify fresh again" after a fresh
|
|
55
|
+
// verdict, each press another paid check).
|
|
55
56
|
export function renderFresh(card, fresh = {}) {
|
|
56
57
|
const doc = card.ownerDocument;
|
|
57
58
|
const of = card.dataset.checkTurnId;
|
|
@@ -68,7 +69,10 @@ export function renderFresh(card, fresh = {}) {
|
|
|
68
69
|
return link;
|
|
69
70
|
};
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
const results = (fresh.verdicts || []).filter((r) => r?.fresh && r.of === of);
|
|
73
|
+
// Once a fresh verdict is in, the button says a press runs another one.
|
|
74
|
+
const label = results.length ? "Verify fresh again" : "Verify fresh";
|
|
75
|
+
for (const result of results) {
|
|
72
76
|
const line = doc.createElement("p");
|
|
73
77
|
line.className = `verify-fresh-result ${ICONS[result.verdict] ? result.verdict : ""}`.trim();
|
|
74
78
|
line.textContent = `fresh: ${ICONS[result.verdict] || ""} ${result.verdict} — ${result.reason || ""}`;
|
|
@@ -89,7 +93,7 @@ export function renderFresh(card, fresh = {}) {
|
|
|
89
93
|
const button = doc.createElement("button");
|
|
90
94
|
button.type = "button";
|
|
91
95
|
button.className = "verify-fresh-btn";
|
|
92
|
-
button.textContent =
|
|
96
|
+
button.textContent = label;
|
|
93
97
|
button.title = fresh.unavailableReason;
|
|
94
98
|
button.disabled = true;
|
|
95
99
|
const note = doc.createElement("p");
|
|
@@ -100,7 +104,7 @@ export function renderFresh(card, fresh = {}) {
|
|
|
100
104
|
const button = doc.createElement("button");
|
|
101
105
|
button.type = "button";
|
|
102
106
|
button.className = "verify-fresh-btn";
|
|
103
|
-
button.textContent =
|
|
107
|
+
button.textContent = label;
|
|
104
108
|
button.title = "A new agent checks this claim against the files, with none of this session's context";
|
|
105
109
|
button.addEventListener("click", () => startFresh(card, button, fresh));
|
|
106
110
|
part.append(button);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Where the Verify preset is offered (verify_preset.js), on both pages: only
|
|
2
|
+
// on text in an ended turn's final answer, the one place the server takes a
|
|
3
|
+
// claim from. Engine#verified_anchor records a Verify turn's card only when
|
|
4
|
+
// its quote is in the last answer of the source turn (AnswerTail.answer?:
|
|
5
|
+
// the turn's last assistant message with text, from its prompt up to the
|
|
6
|
+
// next prompt, merged lines and steers inside); anything else (an earlier
|
|
7
|
+
// reply, a step, a person's line, a reply still running) is refused with
|
|
8
|
+
// `quote_missing` and the turn runs as a plain comment, no card. So the
|
|
9
|
+
// page offers Verify where the server will accept it, and nowhere else.
|
|
10
|
+
//
|
|
11
|
+
// The turn is the page's own grouping (claim_link.js turnIdOf,
|
|
12
|
+
// verdict_card.js turnElements): the last turn opening ([data-turn-id]) at
|
|
13
|
+
// or before the selection, and its elements up to the next opening. Its
|
|
14
|
+
// final answer is the last of them matching the page's answer selector.
|
|
15
|
+
//
|
|
16
|
+
// Pure DOM reads (Element, compareDocumentPosition), so it runs under
|
|
17
|
+
// `node --test` with happy-dom. A module of its own: static JS is cached at
|
|
18
|
+
// unversioned URLs, and a new export on an old module would blank the page.
|
|
19
|
+
|
|
20
|
+
const PRECEDING = 2;
|
|
21
|
+
|
|
22
|
+
// The owner's page: an answer bubble (not a delegate report's reply, which
|
|
23
|
+
// a turn shows before its answer); a context note ends a turn's elements
|
|
24
|
+
// (a wake turn starts with one, and its bubble carries no turn id).
|
|
25
|
+
export const OWNER_FINAL = Object.freeze({ answers: ".bubble.output:not(.report-reply)", boundary: ".bubble.note" });
|
|
26
|
+
// The participant page: a reply's bubble.
|
|
27
|
+
export const PARTICIPANT_FINAL = Object.freeze({ answers: ".p-msg.reply > .p-bubble", boundary: null });
|
|
28
|
+
|
|
29
|
+
// The turn opening +node+ is in (the last [data-turn-id] at or before it in
|
|
30
|
+
// +container+) and whether it is the newest one: {anchor, last}, or null.
|
|
31
|
+
function openingOf(container, node) {
|
|
32
|
+
const anchors = [...container.querySelectorAll("[data-turn-id]")];
|
|
33
|
+
let at = -1;
|
|
34
|
+
anchors.forEach((anchor, i) => {
|
|
35
|
+
if (anchor === node || anchor.contains(node) || (node.compareDocumentPosition(anchor) & PRECEDING)) at = i;
|
|
36
|
+
});
|
|
37
|
+
return at < 0 ? null : { anchor: anchors[at], last: at === anchors.length - 1 };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The final answer of the turn whose opening is +anchor+: the last element
|
|
41
|
+
// matching +answers+ among the opening's siblings after it, up to the next
|
|
42
|
+
// opening or a +boundary+ element. null when it has none.
|
|
43
|
+
function finalAnswer(container, anchor, { answers, boundary }) {
|
|
44
|
+
let top = anchor;
|
|
45
|
+
while (top && top.parentElement !== container) top = top.parentElement;
|
|
46
|
+
if (!top) return null;
|
|
47
|
+
let final = null;
|
|
48
|
+
for (let el = top.nextElementSibling; el; el = el.nextElementSibling) {
|
|
49
|
+
if (el.matches("[data-turn-id]") || el.querySelector("[data-turn-id]")) break;
|
|
50
|
+
if (boundary && el.matches(boundary)) break;
|
|
51
|
+
const inside = [...el.querySelectorAll(answers)];
|
|
52
|
+
if (inside.length) final = inside[inside.length - 1];
|
|
53
|
+
else if (el.matches(answers)) final = el;
|
|
54
|
+
}
|
|
55
|
+
return final;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The turn id Verify anchors to for a selection in +root+ (the annotate
|
|
59
|
+
// source's root) in +container+, or null where Verify isn't offered: not in
|
|
60
|
+
// a turn, not in its final answer, or in the running turn. +rule+:
|
|
61
|
+
// OWNER_FINAL or PARTICIPANT_FINAL. +running+: null when no turn runs, else
|
|
62
|
+
// {turnId} (the running turn's id; null when unknown, then the newest turn
|
|
63
|
+
// counts as the running one) — a running turn's answer isn't final yet.
|
|
64
|
+
export function verifyTurnOf(container, root, rule, { running = null } = {}) {
|
|
65
|
+
if (!container || !root || !container.contains(root)) return null;
|
|
66
|
+
const opening = openingOf(container, root);
|
|
67
|
+
if (!opening) return null;
|
|
68
|
+
const turnId = opening.anchor.dataset.turnId || null;
|
|
69
|
+
if (!turnId) return null;
|
|
70
|
+
if (running && (opening.last || (running.turnId && running.turnId === turnId))) return null;
|
|
71
|
+
const final = finalAnswer(container, opening.anchor, rule);
|
|
72
|
+
return final && (final === root || final.contains(root)) ? turnId : null;
|
|
73
|
+
}
|
data/lib/samagotchi/web/token.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "openssl"
|
|
4
5
|
require "securerandom"
|
|
5
6
|
require_relative "../atomic_file"
|
|
6
7
|
|
|
@@ -14,11 +15,15 @@ module Samagotchi
|
|
|
14
15
|
# can read, $XDG_STATE_HOME/samagotchi/web-token (0600). It is made on
|
|
15
16
|
# the first LAN start and kept, so a phone's bookmark survives
|
|
16
17
|
# restarts; `chi web --new-token` replaces it. A loopback-only chi web
|
|
17
|
-
# never reads it.
|
|
18
|
+
# never reads it. The owner's cookie for this machine's 127.0.0.1
|
|
19
|
+
# holds #loopback_cookie, never the token: a server on any other local
|
|
20
|
+
# port is sent that cookie too.
|
|
18
21
|
module Token
|
|
19
22
|
FILE = "web-token"
|
|
20
23
|
# 32 random bytes: 43 URL-safe characters.
|
|
21
24
|
BYTES = 32
|
|
25
|
+
LOOPBACK_PREFIX = "chl_"
|
|
26
|
+
LOOPBACK_LABEL = "chi web loopback cookie v1"
|
|
22
27
|
|
|
23
28
|
module_function
|
|
24
29
|
|
|
@@ -49,6 +54,20 @@ module Samagotchi
|
|
|
49
54
|
SecureRandom.urlsafe_base64(BYTES)
|
|
50
55
|
end
|
|
51
56
|
|
|
57
|
+
# The owner's 127.0.0.1 cookie on the LAN: an HMAC of a label keyed
|
|
58
|
+
# by +token+, so a leaked one gives nothing about the token, and
|
|
59
|
+
# rotating the token ends it. The gate takes it only as that cookie,
|
|
60
|
+
# from an unproxied loopback peer (App#loopback_cookie_peer?).
|
|
61
|
+
# @return [String, nil] "chl_" and 43 URL-safe characters; nil
|
|
62
|
+
# without a token
|
|
63
|
+
def loopback_cookie(token)
|
|
64
|
+
return nil if token.nil? || token.empty?
|
|
65
|
+
|
|
66
|
+
# URL-safe base64 without padding (pack: base64 isn't a default gem).
|
|
67
|
+
digest = OpenSSL::HMAC.digest("SHA256", token, LOOPBACK_LABEL)
|
|
68
|
+
LOOPBACK_PREFIX + [digest].pack("m0").tr("+/", "-_").delete("=")
|
|
69
|
+
end
|
|
70
|
+
|
|
52
71
|
# Written whole under a temporary name, then renamed over +path+: a
|
|
53
72
|
# reader never sees half a token. The file is 0600 from the start,
|
|
54
73
|
# its folder 0700 when this makes it.
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -251,6 +251,13 @@ module Samagotchi
|
|
|
251
251
|
ChildMove.clear_starting(@session_id, state_dir: @state_dir)
|
|
252
252
|
ChildMove.adopt(@session_id, state_dir: @state_dir)
|
|
253
253
|
@session = Session.load(@session_id, state_dir: @state_dir)
|
|
254
|
+
# A newer chi saved it: this worker keeps the fields it doesn't know
|
|
255
|
+
# but runs the session without them (docs/sessions.md).
|
|
256
|
+
if @session.newer_writer?
|
|
257
|
+
unknown = @session.extra_fields.keys.join(",")
|
|
258
|
+
Log.warn(:worker, "newer_writer", sid: @session_id, version: VERSION, written_by: @session.written_by,
|
|
259
|
+
unknown_fields: (unknown unless unknown.empty?))
|
|
260
|
+
end
|
|
254
261
|
drop_dead_question
|
|
255
262
|
# The running turn, on disk at its iteration boundaries.
|
|
256
263
|
@checkpoint = TurnCheckpoint.new(session: @session, state_dir: @state_dir)
|