samagotchi 0.100.0 → 0.101.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 +39 -1
- data/bin/chi +1 -0
- data/docs/architecture.md +10 -1
- data/docs/cli.md +55 -0
- data/docs/configuration.md +1 -0
- data/docs/hooks.md +2 -0
- data/docs/internals/engine.md +1 -0
- data/docs/internals/working-set.md +130 -0
- data/docs/sessions.md +1 -1
- data/docs/testing.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/bundles/work-cap/manifest.yml +2 -2
- data/lib/samagotchi/bundles/work-cap/plugin.rb +26 -16
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/engine.rb +30 -0
- data/lib/samagotchi/hooks/loader.rb +17 -2
- data/lib/samagotchi/host_keys.rb +35 -18
- data/lib/samagotchi/testing/fake_executable.rb +6 -16
- data/lib/samagotchi/testing/fake_executable_launcher +6 -0
- data/lib/samagotchi/tool_response.rb +10 -6
- data/lib/samagotchi/tool_runner.rb +25 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +49 -1
- data/lib/samagotchi/web/public/app.js +77 -11
- data/lib/samagotchi/web/public/chains.js +24 -0
- data/lib/samagotchi/web/public/chip_popover.js +56 -17
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/index.html +134 -2
- data/lib/samagotchi/web/public/info_bar.js +16 -4
- data/lib/samagotchi/web/public/info_bar_model.js +7 -3
- data/lib/samagotchi/web/public/markdown_place.js +47 -1
- data/lib/samagotchi/web/public/refs.js +10 -1
- data/lib/samagotchi/web/public/send_turn.js +6 -2
- data/lib/samagotchi/web/public/session_actions.js +10 -5
- data/lib/samagotchi/web/public/stage_model.js +6 -5
- data/lib/samagotchi/web/public/stage_view.js +34 -2
- data/lib/samagotchi/web/public/stream_handlers.js +6 -1
- data/lib/samagotchi/web/public/turn_view.js +37 -11
- data/lib/samagotchi/web/public/working_set.js +520 -0
- data/lib/samagotchi/web/public/working_set_rail.js +278 -0
- data/lib/samagotchi/web/public/working_set_view.js +426 -0
- data/lib/samagotchi/web/server.rb +6 -4
- data/lib/samagotchi/working_set.rb +200 -0
- metadata +7 -1
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
// The working set's page parts (web.working_set, the stage view): the turns'
|
|
2
|
+
// sets the page keeps (a reload's, then one per hand-off), the footprint
|
|
3
|
+
// line under each finished turn's block, the session bar's "set N" chip,
|
|
4
|
+
// the expanded view (a chip_popover.js popover; a bottom sheet on a phone)
|
|
5
|
+
// and the step tags' jump to a call. The rail is working_set_rail.js's; the
|
|
6
|
+
// rules are working_set.js's.
|
|
7
|
+
//
|
|
8
|
+
// The server's projection (WorkingSet: a :working_set_updated event, a GET
|
|
9
|
+
// working_set when a session's set is seeded and when the expanded view
|
|
10
|
+
// opens) is one session-wide snapshot, the newer by its at kept; every form
|
|
11
|
+
// overlays its state and tokens on the rows' items by key (working_set.js
|
|
12
|
+
// overlay), so a state is the item's now, not then. A new snapshot redraws
|
|
13
|
+
// them all: the chip, the open popover, the footprints in place and the rail
|
|
14
|
+
// (onServer).
|
|
15
|
+
//
|
|
16
|
+
// A row's place is ordinals, stamped on the DOM: data-turn on a turn's
|
|
17
|
+
// .turn-work block (its place in the page's turns) and data-at="step.row"
|
|
18
|
+
// on its tool rows, live (stampRows) and reloaded (stampHistory); a jump
|
|
19
|
+
// finds the row by them, never by a row key.
|
|
20
|
+
import { html, nothing, render, svg } from "./vendor/lit-html.js";
|
|
21
|
+
import { createChipPopover } from "./chip_popover.js";
|
|
22
|
+
import { showRow } from "./refs.js";
|
|
23
|
+
import {
|
|
24
|
+
BUDGETS, fold, footprint, footprintText, GLYPHS, isFresh, itemsOfTurn, labelText, labelTitle, newerSnapshot, opsText, overlay,
|
|
25
|
+
sessionItems, stepRange, stepTag, tokensText,
|
|
26
|
+
} from "./working_set.js";
|
|
27
|
+
|
|
28
|
+
// The popover's files fold to this many lines (groups open in place, one at
|
|
29
|
+
// a time), its memories past this many to one group.
|
|
30
|
+
const POP_BUDGET = 6;
|
|
31
|
+
|
|
32
|
+
// A reloaded turn's items: its steps (timing.js turnGroups) as gens.
|
|
33
|
+
export function groupItems(group, ordinal) {
|
|
34
|
+
return itemsOfTurn({ gens: (group.steps || []).map((step) => ({ tools: step.tools || [] })) }, ordinal);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The live turn's block and rows stamped with their ordinals (+turn+: the
|
|
38
|
+
// turn's model; new rows only).
|
|
39
|
+
export function stampRows(blockEl, turn, ordinal) {
|
|
40
|
+
if (!blockEl || !turn) return;
|
|
41
|
+
if (blockEl.dataset.turn !== String(ordinal)) blockEl.dataset.turn = String(ordinal);
|
|
42
|
+
turn.gens.forEach((gen, step) => {
|
|
43
|
+
gen.tools.forEach((row, i) => {
|
|
44
|
+
const el = blockEl.querySelector(`.activity-row[data-key="${String(row.key).replace(/"/g, '\\"')}"]`);
|
|
45
|
+
if (el && !el.dataset.at) el.dataset.at = `${step}.${i}`;
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A reloaded history's blocks stamped: +blocks+ the .turn-work elements,
|
|
51
|
+
// +ordinals+ each one's turn.
|
|
52
|
+
export function stampHistory(blocks, ordinals) {
|
|
53
|
+
blocks.forEach((block, i) => {
|
|
54
|
+
block.dataset.turn = String(ordinals[i]);
|
|
55
|
+
[...block.querySelectorAll(":scope > .gen")].forEach((gen, step) => {
|
|
56
|
+
[...gen.querySelectorAll(":scope > .activity-body > .activity-row")].forEach((row, j) => { row.dataset.at = `${step}.${j}`; });
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function atAttr(at) {
|
|
62
|
+
return `${at.turn}.${at.step}.${at.row}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A label's step tag in the footprint: a memory's step, a memory group's
|
|
66
|
+
// range (it jumps to the first); none for files and URLs.
|
|
67
|
+
function footTag(label) {
|
|
68
|
+
if (label.kind !== "memory") return nothing;
|
|
69
|
+
const first = label.members.find((m) => m.at);
|
|
70
|
+
if (!first) return nothing;
|
|
71
|
+
const text = label.type === "leaf" ? stepTag(first) : stepRange(label.members);
|
|
72
|
+
const title = label.type === "leaf" ? `entered at step ${first.at.step + 1}: go to the call` : `entered at ${text}: go to the first call`;
|
|
73
|
+
return html` <button type="button" class="ws-step p" data-ws="jump" data-at=${atAttr(first.at)} title=${title}>${text}</button>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The footprint line of a finished turn's +items+ (turn +ordinal+), nothing
|
|
77
|
+
// for a turn that touched nothing: "touched web/public/ · 9 files · ◆ 3
|
|
78
|
+
// memories s1–5 · +1 url · edited 2". A click opens the expanded view at
|
|
79
|
+
// that turn; a step tag jumps to its call.
|
|
80
|
+
export function footprintTemplate(items, ordinal) {
|
|
81
|
+
const fp = footprint(items, BUDGETS.footprint);
|
|
82
|
+
if (!fp) return nothing;
|
|
83
|
+
const sep = html` <span class="sep">·</span> `;
|
|
84
|
+
const parts = fp.labels.map((l, i) => html`${i ? sep : nothing}<span class=${`l ${l.kind}${l.state && l.state !== "live" ? ` ${l.state}` : ""}`} title=${labelTitle(l)}>${l.glyph ? html`<span class="k">${l.glyph}</span> ` : nothing}${labelText({ ...l, glyph: "" })}</span>${footTag(l)}`);
|
|
85
|
+
const text = footprintText(items, BUDGETS.footprint);
|
|
86
|
+
return html`<div class="ws-foot" role="button" tabindex="0" data-ws-turn=${ordinal} data-ws-sig=${footSig(items)} aria-label=${`Working set of this turn: ${text.replace(/^touched /, "")}. Open the list.`}><span class="w">touched</span> ${parts}${fp.tail ? html`${sep}<span class="w">${fp.tail}</span>` : nothing}${fp.edited ? html`${sep}<span class="e">edited ${fp.edited}</span>` : nothing}${fp.forgotten ? html`${sep}<span class="f">${fp.forgotten} forgotten</span>` : nothing}</div>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// What a footprint shows that can change after it is drawn: its items'
|
|
90
|
+
// states (the server's). The same signature draws nothing.
|
|
91
|
+
function footSig(items) {
|
|
92
|
+
return items.map((i) => `${i.kind}:${i.key}:${i.state || "live"}`).join("|");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The footprint as an element (the hand-off's), or null.
|
|
96
|
+
export function footprintNode(items, ordinal) {
|
|
97
|
+
const template = footprintTemplate(items, ordinal);
|
|
98
|
+
if (template === nothing) return null;
|
|
99
|
+
const frag = document.createDocumentFragment();
|
|
100
|
+
render(template, frag);
|
|
101
|
+
return frag.querySelector(".ws-foot");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function ringTemplate(pct) {
|
|
105
|
+
if (!Number.isFinite(pct)) return nothing;
|
|
106
|
+
const value = Math.max(0, Math.min(100, Math.round(pct)));
|
|
107
|
+
const len = 2 * Math.PI * 5.5;
|
|
108
|
+
const bucket = value >= 90 ? " danger" : value >= 70 ? " warn" : "";
|
|
109
|
+
return html`<svg class=${`ws-ring${bucket}`} viewBox="0 0 14 14" role="img">${svg`<title>context ${value}% used</title><circle class="track" cx="7" cy="7" r="5.5"></circle><circle class="fill" cx="7" cy="7" r="5.5" stroke-dasharray=${`${(len * value) / 100} ${len}`}></circle>`}</svg>`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A group's member as the popover names it: its path below the group's
|
|
113
|
+
// directory ("adapters/by_api.rb"), else its leaf text.
|
|
114
|
+
function memberName(item, group) {
|
|
115
|
+
if (item.kind === "file" && group?.dir) return String(item.path || item.key).slice(group.dir.length + 1);
|
|
116
|
+
return item.label;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// +historyEl+: where finished turns are; +stageRoot()+: the stage (the live
|
|
120
|
+
// turn's block); +state()+: the open session's fields (session_state.js:
|
|
121
|
+
// ctxPct and the preloaded, muted and used memories); +session()+: its id;
|
|
122
|
+
// +infoEl+: the session bar's line (the chip's popover is delegated there);
|
|
123
|
+
// +onChange()+: the chip's count or dot changed (app.js updateInfoBar).
|
|
124
|
+
// +getWorkingSet(id)+: the server's projection (data.js; null: none asked).
|
|
125
|
+
export function createWorkingSet({ historyEl, stageRoot = () => null, state, session = () => null, infoEl, onChange = () => {}, getWorkingSet = null, doc = document }) {
|
|
126
|
+
// The finished turns' sets ({items, prompt}), the running turn's (null:
|
|
127
|
+
// none), the rail's first-sight times (its fresh cue), the session count
|
|
128
|
+
// at the last open (the chip's dot).
|
|
129
|
+
let turns = [];
|
|
130
|
+
let live = null;
|
|
131
|
+
let seenAt = () => undefined;
|
|
132
|
+
let openedCount = 0;
|
|
133
|
+
// The open view: its scope ("turn" / "session"), its turn, the group open
|
|
134
|
+
// in it.
|
|
135
|
+
let view = { scope: "session", turn: null };
|
|
136
|
+
let openGroup = null;
|
|
137
|
+
// The server's snapshot ({items, at}) and the session it is of; what
|
|
138
|
+
// redraws the rail when it changes.
|
|
139
|
+
let server = null;
|
|
140
|
+
let serverOf = null;
|
|
141
|
+
const listeners = new Set();
|
|
142
|
+
const lit = (items) => overlay(items, server);
|
|
143
|
+
|
|
144
|
+
const sessionSet = () => {
|
|
145
|
+
const st = state();
|
|
146
|
+
const set = sessionItems({
|
|
147
|
+
turns: turns.map((t) => t.items), live: live?.items || [], preloaded: st.preloadedMemories, muted: st.mutedMemories,
|
|
148
|
+
used: st.usedMemories,
|
|
149
|
+
});
|
|
150
|
+
return { items: lit(set.items), muted: set.muted };
|
|
151
|
+
};
|
|
152
|
+
const turnItems = (t) => lit(live && t === live.ordinal ? live.items : turns[t]?.items || []);
|
|
153
|
+
const promptOf = (t) => (live && t === live.ordinal ? live.prompt : turns[t]?.prompt || "");
|
|
154
|
+
|
|
155
|
+
const popover = createChipPopover({
|
|
156
|
+
id: "workingSetPopover", className: "ctx-popover ws-popover", width: 420, el: infoEl, anchor: ".ws-chip",
|
|
157
|
+
openers: ".ws-chip, .ts-ws, .ws-foot", sheet: true,
|
|
158
|
+
// The chip opens the session's scope; the rail and a footprint set
|
|
159
|
+
// theirs before they open it.
|
|
160
|
+
summary: (anchor) => {
|
|
161
|
+
if (anchor?.matches?.(".ws-chip")) {
|
|
162
|
+
view = { scope: "session", turn: live ? live.ordinal : turns.length - 1 };
|
|
163
|
+
// Opened: the chip's dot goes (chip()).
|
|
164
|
+
queueMicrotask(onChange);
|
|
165
|
+
}
|
|
166
|
+
return { view };
|
|
167
|
+
},
|
|
168
|
+
session,
|
|
169
|
+
// Opened: the server's set asked again (a file changed while the
|
|
170
|
+
// session sat idle shows stale).
|
|
171
|
+
show: (_data, opening) => {
|
|
172
|
+
draw();
|
|
173
|
+
if (opening) fetchServer();
|
|
174
|
+
},
|
|
175
|
+
onClose: () => { openGroup = null; onChange(); },
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
function rowTemplate(item, { session: inSession, group = null, now }) {
|
|
179
|
+
const fresh = isFresh(seenAt(item), now);
|
|
180
|
+
const tag = stepTag(item, { session: inSession });
|
|
181
|
+
const words = `${opsText(item)}${fresh ? " · just now" : ""}`;
|
|
182
|
+
const glyph = item.muted ? GLYPHS.muted : fresh ? "●" : GLYPHS[item.kind] || "";
|
|
183
|
+
const name = memberName(item, group);
|
|
184
|
+
const full = item.kind === "file" ? item.path || item.key : item.kind === "url" ? item.url || item.key : item.key;
|
|
185
|
+
let nameEl;
|
|
186
|
+
if (item.kind === "file") {
|
|
187
|
+
const ref = item.ref || { path: full };
|
|
188
|
+
nameEl = html`<span class="t ref" data-ref-kind="file" data-ref-path=${ref.path || full} data-ref-line=${ref.line || nothing} data-ref-end-line=${ref.end_line || nothing} title=${full}>${name}</span>`;
|
|
189
|
+
} else if (item.kind === "url" && /^https?:\/\//i.test(full)) {
|
|
190
|
+
nameEl = html`<a class="t" href=${full} target="_blank" rel="noopener noreferrer" title=${full}>${name}</a>`;
|
|
191
|
+
} else {
|
|
192
|
+
nameEl = html`<span class="t" title=${full}>${name}</span>`;
|
|
193
|
+
}
|
|
194
|
+
let tagEl = nothing;
|
|
195
|
+
if (tag === "prompt") tagEl = html`<span class="ws-step p" title="preloaded into the system prompt">prompt</span>`;
|
|
196
|
+
else if (tag) {
|
|
197
|
+
const prompt = inSession ? promptOf(item.at.turn) : "";
|
|
198
|
+
const where = inSession ? `turn ${item.at.turn + 1}${prompt ? ` (${prompt})` : ""}, step ${item.at.step + 1}` : `step ${item.at.step + 1}`;
|
|
199
|
+
tagEl = html`<button type="button" class=${`ws-step${item.kind === "memory" ? " p" : ""}`} data-ws="jump" data-at=${atAttr(item.at)} title=${`entered at ${where}: go to the call`}>${tag}</button>`;
|
|
200
|
+
}
|
|
201
|
+
const edited = item.kind === "file" && item.ops.includes("edit");
|
|
202
|
+
const state = item.state && item.state !== "live" ? ` ${item.state}` : "";
|
|
203
|
+
const tokens = tokensText(item.tokens);
|
|
204
|
+
return html`<div class=${`ws-row ${item.kind}${fresh ? " fresh" : ""}${item.muted ? " muted" : ""}${group ? " member" : ""}${state}`}><span class="k">${glyph}</span>${nameEl}${edited ? html`<span class="e">✎</span>` : nothing}<span class="st">${words}</span>${tokens ? html`<span class="tk" title=${`about ${item.tokens} tokens of it are sent`}>${tokens}</span>` : nothing}${tagEl}</div>`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// A section's labels: leaves as rows, groups as an opener row and, open,
|
|
208
|
+
// their members.
|
|
209
|
+
function labelsTemplate(labels, opts) {
|
|
210
|
+
return labels.map((label) => {
|
|
211
|
+
if (label.type === "leaf") return rowTemplate(label.members[0], opts);
|
|
212
|
+
const open = openGroup === label.id;
|
|
213
|
+
const state = label.state && label.state !== "live" ? ` ${label.state}` : "";
|
|
214
|
+
const head = html`<button type="button" class=${`ws-row ws-group-head ${label.kind}${state}`} data-ws="group" data-id=${label.id} aria-expanded=${String(open)} title=${labelTitle(label)}><span class="k">${open ? "▾" : "▸"}</span><span class="t">${label.glyph ? `${label.glyph} ` : ""}${label.text}</span>${label.count ? html`<span class="n">· ${label.count}</span>` : nothing}${label.edited ? html`<span class="e">✎${label.edited}</span>` : nothing}${label.cut ? html`<span class="c" title=${`${label.cut} forgotten`}>✂${label.cut}</span>` : nothing}<span class="chev">${open ? "close" : "open"}</span></button>`;
|
|
215
|
+
return html`${head}${open ? label.members.map((m) => rowTemplate(m, { ...opts, group: label })) : nothing}`;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function draw() {
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
const st = state();
|
|
222
|
+
const inSession = view.scope === "session";
|
|
223
|
+
const set = inSession ? sessionSet() : { items: turnItems(view.turn), muted: [] };
|
|
224
|
+
const files = set.items.filter((i) => i.kind === "file");
|
|
225
|
+
const memories = set.items.filter((i) => i.kind === "memory");
|
|
226
|
+
const urls = set.items.filter((i) => i.kind === "url");
|
|
227
|
+
const opts = { session: inSession, now };
|
|
228
|
+
const fileLabels = fold(files, POP_BUDGET).labels;
|
|
229
|
+
// Memories are rows of their own; past the budget one group that opens.
|
|
230
|
+
const memoryLabels = fold(memories, memories.length > POP_BUDGET ? 1 : Infinity).labels;
|
|
231
|
+
const preloaded = memories.filter((m) => m.preloaded).length;
|
|
232
|
+
const memMeta = [`${memories.length - preloaded} used`, preloaded ? `${preloaded} preloaded` : "", set.muted.length ? `${set.muted.length} muted` : ""].filter(Boolean).join(" · ");
|
|
233
|
+
const turnLabel = view.turn !== null && view.turn >= 0 ? `turn ${view.turn + 1}` : "this turn";
|
|
234
|
+
const pct = Number.isFinite(st.ctxPct) ? ` · ctx ${Math.round(st.ctxPct)} %` : "";
|
|
235
|
+
const empty = !set.items.length && !set.muted.length;
|
|
236
|
+
const tpl = html`<div class="ctx-pop-head"><strong>working set</strong><span class="ws-scope" role="group" aria-label="Scope"><button type="button" class=${!inSession ? "on" : ""} aria-pressed=${String(!inSession)} data-ws="scope" data-scope="turn" ?disabled=${view.turn === null || view.turn < 0}>${turnLabel}</button><button type="button" class=${inSession ? "on" : ""} aria-pressed=${String(inSession)} data-ws="scope" data-scope="session">session</button></span><span class="ctx-pop-kind">${set.items.length} item${set.items.length === 1 ? "" : "s"}${pct}</span>${ringTemplate(st.ctxPct)}<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>
|
|
237
|
+
${empty ? html`<div class="ctx-pop-meta">Nothing yet: the files, memories and URLs ${inSession ? "this session's turns read" : "this turn reads"} show here.</div>` : nothing}
|
|
238
|
+
${files.length ? html`<div class="ctx-pop-meta">files · ${files.length}</div>${labelsTemplate(fileLabels, opts)}` : nothing}
|
|
239
|
+
${memories.length || set.muted.length ? html`<div class="ctx-pop-meta">memories${memMeta ? ` · ${memMeta}` : ""}</div>${labelsTemplate(memoryLabels, opts)}${set.muted.map((m) => rowTemplate(m, opts))}` : nothing}
|
|
240
|
+
${urls.length ? html`<div class="ctx-pop-meta">urls · ${urls.length}</div>${labelsTemplate(fold(urls, POP_BUDGET).labels, opts)}` : nothing}
|
|
241
|
+
<div class="ctx-pop-meta ws-hint">A file opens its ref; a step tag goes to the call. Esc closes.</div>`;
|
|
242
|
+
render(tpl, popover.pop);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// The row at +at+ ({turn, step, row}): the stage's block for the live
|
|
246
|
+
// turn, else the history's.
|
|
247
|
+
function rowAt(at) {
|
|
248
|
+
const sel = `.turn-work[data-turn="${at.turn}"]`;
|
|
249
|
+
const blocks = [...(stageRoot()?.querySelectorAll(sel) || []), ...[...historyEl.querySelectorAll(sel)].reverse()];
|
|
250
|
+
for (const block of blocks) {
|
|
251
|
+
const row = block.querySelector(`.activity-row[data-at="${at.step}.${at.row}"]`);
|
|
252
|
+
if (row) return row;
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function jump(at) {
|
|
258
|
+
const row = rowAt(at);
|
|
259
|
+
if (!row) return false;
|
|
260
|
+
if (popover.isOpen()) popover.close();
|
|
261
|
+
showRow(row);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const parseAt = (text) => {
|
|
266
|
+
const [turn, step, row] = String(text).split(".").map(Number);
|
|
267
|
+
return [turn, step, row].every(Number.isInteger) ? { turn, step, row } : null;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
popover.pop.addEventListener("click", (e) => {
|
|
271
|
+
const act = e.target.closest("[data-ws]");
|
|
272
|
+
if (!act) return;
|
|
273
|
+
if (act.dataset.ws === "jump") {
|
|
274
|
+
const at = parseAt(act.dataset.at);
|
|
275
|
+
if (at) jump(at);
|
|
276
|
+
} else if (act.dataset.ws === "scope") {
|
|
277
|
+
view = { ...view, scope: act.dataset.scope };
|
|
278
|
+
openGroup = null;
|
|
279
|
+
draw();
|
|
280
|
+
popover.replace();
|
|
281
|
+
} else if (act.dataset.ws === "group") {
|
|
282
|
+
openGroup = openGroup === act.dataset.id ? null : act.dataset.id;
|
|
283
|
+
draw();
|
|
284
|
+
popover.replace();
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// A footprint: its step tag jumps, the rest opens that turn's view.
|
|
289
|
+
function footClick(e) {
|
|
290
|
+
const foot = e.target.closest?.(".ws-foot");
|
|
291
|
+
if (!foot) return;
|
|
292
|
+
const tag = e.target.closest(".ws-step[data-at]");
|
|
293
|
+
if (tag) {
|
|
294
|
+
const at = parseAt(tag.dataset.at);
|
|
295
|
+
if (at) jump(at);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (popover.isOpen() && popover.opener() === foot) return popover.close();
|
|
299
|
+
view = { scope: "turn", turn: Number(foot.dataset.wsTurn) };
|
|
300
|
+
popover.open(foot);
|
|
301
|
+
}
|
|
302
|
+
doc.addEventListener("click", footClick);
|
|
303
|
+
doc.addEventListener("keydown", (e) => {
|
|
304
|
+
if ((e.key === "Enter" || e.key === " ") && e.target.matches?.(".ws-foot")) {
|
|
305
|
+
e.preventDefault();
|
|
306
|
+
footClick(e);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
function changed() {
|
|
311
|
+
onChange();
|
|
312
|
+
if (popover.isOpen()) popover.refresh();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The footprints on the page (the history's, the hand-off's) drawn again
|
|
316
|
+
// in place when their items' states changed: the same node (its focus,
|
|
317
|
+
// the popover's opener), its children and label replaced.
|
|
318
|
+
function refreshFootprints() {
|
|
319
|
+
for (const root of [historyEl, stageRoot()]) {
|
|
320
|
+
for (const foot of root?.querySelectorAll(".ws-foot[data-ws-turn]") || []) {
|
|
321
|
+
const ordinal = Number(foot.dataset.wsTurn);
|
|
322
|
+
if (!Number.isInteger(ordinal)) continue;
|
|
323
|
+
const items = turnItems(ordinal);
|
|
324
|
+
if (!items.length || foot.dataset.wsSig === footSig(items)) continue;
|
|
325
|
+
const next = footprintNode(items, ordinal);
|
|
326
|
+
if (!next) continue;
|
|
327
|
+
foot.replaceChildren(...next.childNodes);
|
|
328
|
+
foot.setAttribute("aria-label", next.getAttribute("aria-label"));
|
|
329
|
+
foot.dataset.wsSig = next.dataset.wsSig;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// A newer server snapshot redraws every form; an older one (a GET that
|
|
335
|
+
// answered after a newer event) is dropped.
|
|
336
|
+
function takeServer(snapshot) {
|
|
337
|
+
const next = newerSnapshot(server, snapshot);
|
|
338
|
+
if (next === server) return;
|
|
339
|
+
server = next;
|
|
340
|
+
serverOf = session();
|
|
341
|
+
changed();
|
|
342
|
+
refreshFootprints();
|
|
343
|
+
for (const fn of listeners) fn();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The server's set for the open session, asked again; an answer for a
|
|
347
|
+
// session no longer open is dropped.
|
|
348
|
+
function fetchServer() {
|
|
349
|
+
const id = session();
|
|
350
|
+
if (!getWorkingSet || !id) return;
|
|
351
|
+
Promise.resolve()
|
|
352
|
+
.then(() => getWorkingSet(id))
|
|
353
|
+
.then((snapshot) => { if (session() === id) takeServer(snapshot); })
|
|
354
|
+
.catch(() => {});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
// A session's history drawn anew: its turns' sets (+list+: [{items,
|
|
359
|
+
// prompt}]), no running turn yet; the chip's dot starts off.
|
|
360
|
+
seed(list) {
|
|
361
|
+
turns = list.slice();
|
|
362
|
+
live = null;
|
|
363
|
+
if (popover.isOpen()) popover.close();
|
|
364
|
+
// Another session's snapshot goes; the same session's (a resync)
|
|
365
|
+
// stays until the newer one comes.
|
|
366
|
+
if (serverOf !== session()) {
|
|
367
|
+
server = null;
|
|
368
|
+
serverOf = session();
|
|
369
|
+
}
|
|
370
|
+
openedCount = sessionSet().items.length;
|
|
371
|
+
refreshFootprints();
|
|
372
|
+
fetchServer();
|
|
373
|
+
},
|
|
374
|
+
// A :working_set_updated event's set ({items, at}).
|
|
375
|
+
update(snapshot) { takeServer(snapshot); },
|
|
376
|
+
// +fn()+ runs after each new server snapshot (the rail's redraw).
|
|
377
|
+
onServer(fn) { listeners.add(fn); },
|
|
378
|
+
// The running turn's items (its model +turn+), its ordinal the next
|
|
379
|
+
// turn's; the chip follows.
|
|
380
|
+
liveItems(turn, prompt = "") {
|
|
381
|
+
const ordinal = turns.length;
|
|
382
|
+
const items = itemsOfTurn(turn, ordinal);
|
|
383
|
+
const before = live?.items.length ?? -1;
|
|
384
|
+
live = { ordinal, items, prompt };
|
|
385
|
+
if (items.length !== before) changed();
|
|
386
|
+
return { items: lit(items), ordinal };
|
|
387
|
+
},
|
|
388
|
+
// The rail's first-sight times, for the popover's fresh rows.
|
|
389
|
+
setSeen(fn) { seenAt = fn; },
|
|
390
|
+
// The running turn hands off: its set joins the turns; its footprint
|
|
391
|
+
// line (null: it touched nothing) goes after its block.
|
|
392
|
+
handOff(turn, prompt = "") {
|
|
393
|
+
const ordinal = turns.length;
|
|
394
|
+
const items = itemsOfTurn(turn, ordinal);
|
|
395
|
+
turns.push({ items, prompt });
|
|
396
|
+
live = null;
|
|
397
|
+
changed();
|
|
398
|
+
return footprintNode(lit(items), ordinal);
|
|
399
|
+
},
|
|
400
|
+
// The running turn left without a hand-off (a reset): forgotten.
|
|
401
|
+
release() {
|
|
402
|
+
if (!live) return;
|
|
403
|
+
live = null;
|
|
404
|
+
changed();
|
|
405
|
+
},
|
|
406
|
+
// The rail opens the running turn's view.
|
|
407
|
+
openTurn(anchor) {
|
|
408
|
+
if (popover.isOpen() && popover.opener() === anchor) return popover.close();
|
|
409
|
+
view = { scope: "turn", turn: live ? live.ordinal : turns.length - 1 };
|
|
410
|
+
popover.open(anchor);
|
|
411
|
+
openedCount = sessionSet().items.length;
|
|
412
|
+
onChange();
|
|
413
|
+
},
|
|
414
|
+
// The session bar's chip: {n, changed} (the dot), null when the set is
|
|
415
|
+
// empty (no chip). Opening it clears the dot.
|
|
416
|
+
chip() {
|
|
417
|
+
const n = sessionSet().items.length;
|
|
418
|
+
if (popover.isOpen()) openedCount = n;
|
|
419
|
+
return n ? { n, changed: n !== openedCount } : null;
|
|
420
|
+
},
|
|
421
|
+
jump,
|
|
422
|
+
ctxPct: () => state().ctxPct,
|
|
423
|
+
isOpen: () => popover.isOpen(),
|
|
424
|
+
popover,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
@@ -66,7 +66,8 @@ module Samagotchi
|
|
|
66
66
|
# @return [Integer] the exit status
|
|
67
67
|
def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false,
|
|
68
68
|
view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
|
|
69
|
-
editor: Config::BY_KEY["web.editor"].default, new_token: false
|
|
69
|
+
editor: Config::BY_KEY["web.editor"].default, new_token: false,
|
|
70
|
+
working_set: Config::BY_KEY["web.working_set"].default)
|
|
70
71
|
rotate_token if new_token
|
|
71
72
|
setting = host_setting(host)
|
|
72
73
|
lan = Lan.wanted?(setting) ? Lan.choose(setting) : nil
|
|
@@ -95,7 +96,7 @@ module Samagotchi
|
|
|
95
96
|
return 0
|
|
96
97
|
end
|
|
97
98
|
if start(port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, view: view,
|
|
98
|
-
annotate_presets: annotate_presets, editor: editor, lan: lan)
|
|
99
|
+
annotate_presets: annotate_presets, editor: editor, lan: lan, working_set: working_set)
|
|
99
100
|
0
|
|
100
101
|
else
|
|
101
102
|
1
|
|
@@ -119,7 +120,8 @@ module Samagotchi
|
|
|
119
120
|
# @return [Boolean] false when the port was taken (said so on stderr)
|
|
120
121
|
def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
|
|
121
122
|
view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
|
|
122
|
-
editor: Config::BY_KEY["web.editor"].default, token_path: Token.path
|
|
123
|
+
editor: Config::BY_KEY["web.editor"].default, token_path: Token.path,
|
|
124
|
+
working_set: Config::BY_KEY["web.working_set"].default)
|
|
123
125
|
config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :web) do |message|
|
|
124
126
|
Samagotchi::Log.warn(:config, "restart_required", echo: "Warning: config: #{message}")
|
|
125
127
|
end
|
|
@@ -133,7 +135,7 @@ module Samagotchi
|
|
|
133
135
|
lan_option = { ip: lan.ip, token: Token::Source.new(token_path) }
|
|
134
136
|
end
|
|
135
137
|
app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, view: view,
|
|
136
|
-
annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option)
|
|
138
|
+
annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option, working_set: working_set)
|
|
137
139
|
Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
|
|
138
140
|
Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
|
|
139
141
|
# Subscribed before the first scan, which makes the first check.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tool_response"
|
|
4
|
+
require_relative "tool_ids"
|
|
5
|
+
require_relative "token_usage"
|
|
6
|
+
require_relative "file_ref"
|
|
7
|
+
require_relative "llm_context_edit"
|
|
8
|
+
require_relative "llm_context_stale"
|
|
9
|
+
require_relative "llm_context_view"
|
|
10
|
+
require_relative "tools/memory"
|
|
11
|
+
|
|
12
|
+
module Samagotchi
|
|
13
|
+
# The working set as the model really has it (the web's rail, chip,
|
|
14
|
+
# popover and footprints overlay it, web.working_set): every file,
|
|
15
|
+
# memory and URL the conversation's tool runs touched, each still +live+,
|
|
16
|
+
# +stale+ (a file changed since the model last saw it) or +forgotten+
|
|
17
|
+
# (nothing of it is still sent under the session's current strategy),
|
|
18
|
+
# with what its content outputs cost in tokens as sent. Pure: built from
|
|
19
|
+
# a conversation, its folder, a strategy and a stat, no session, no
|
|
20
|
+
# Engine. See docs/internals/working-set.md.
|
|
21
|
+
#
|
|
22
|
+
# The runs are the ones that pair with their calls for sure
|
|
23
|
+
# (LLMContextStale.paired_runs, the call as dispatched: aliases resolve
|
|
24
|
+
# as the loops resolve them); a "ran as:" corrected call or an output
|
|
25
|
+
# holding the run separator pairs with none and is left out. A failed run
|
|
26
|
+
# (its output an "Error", a denied call's too) and an interrupted one
|
|
27
|
+
# touch nothing.
|
|
28
|
+
#
|
|
29
|
+
# Content outputs (read, memory_read, web_fetch) carry the tokens and
|
|
30
|
+
# decide forgotten; edit, write and memory_write add ops and stamps only
|
|
31
|
+
# (their content is in the call's arguments, which no strategy cuts).
|
|
32
|
+
# Stale: the item's latest run left a stamp (ToolRun#stamp) and the file
|
|
33
|
+
# now differs from it in mtime or size, or is gone; a run with no stamp
|
|
34
|
+
# (an old session's) never makes a file stale.
|
|
35
|
+
module WorkingSet
|
|
36
|
+
FILE = "file"
|
|
37
|
+
MEMORY = "memory"
|
|
38
|
+
URL = "url"
|
|
39
|
+
LIVE = "live"
|
|
40
|
+
STALE = "stale"
|
|
41
|
+
FORGOTTEN = "forgotten"
|
|
42
|
+
|
|
43
|
+
# One thing the model touched: +kind+ file|memory|url; +key+ a file's
|
|
44
|
+
# absolute path (FileRef), a memory's name, a URL (whitespace
|
|
45
|
+
# collapsed); +state+ live|stale|forgotten; +tokens+ its content
|
|
46
|
+
# outputs as sent; +ops+ read|edit for a file, read|write for a memory,
|
|
47
|
+
# read for a URL, in first-seen order; +count+ the runs that touched
|
|
48
|
+
# it; +first_id+ the first one's chi id (ToolIds).
|
|
49
|
+
Item = Data.define(:kind, :key, :state, :tokens, :ops, :count, :first_id)
|
|
50
|
+
|
|
51
|
+
# A projection and the conversation's size (entries) it was built
|
|
52
|
+
# from: the web keeps the newer of an event's and a GET's by it.
|
|
53
|
+
Snapshot = Data.define(:items, :at) do
|
|
54
|
+
def empty? = items.empty?
|
|
55
|
+
|
|
56
|
+
# The JSON the event and the route carry: {items: [...], at:}.
|
|
57
|
+
def to_h = { items: items.map(&:to_h), at: at }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
FILE_OPS = { "read" => "read", "edit" => "edit", "write" => "edit" }.freeze
|
|
61
|
+
CONTENT = %w[read memory_read web_fetch].freeze
|
|
62
|
+
MEMORY_READ = Tools::MemoryRead::NAME
|
|
63
|
+
MEMORY_WRITE = Tools::MemoryWrite::NAME
|
|
64
|
+
WEB_FETCH = "web_fetch"
|
|
65
|
+
# A stale stub that an edit (not a later read) made: the model wrote
|
|
66
|
+
# the file, so what it has of it is still current (llm_context.stale_edits).
|
|
67
|
+
BY_EDIT = /superseded by a later (?:edit|write)\z/
|
|
68
|
+
# A partial memory_read's last part (Tools::MemoryRead.call).
|
|
69
|
+
MISSING = /\AError: memory not found: (.*)\z/m
|
|
70
|
+
|
|
71
|
+
# What one paired run touched: its item's identity and op, whether its
|
|
72
|
+
# output is content, its stored and sent texts, its stamp.
|
|
73
|
+
Touch = Data.define(:kind, :key, :op, :content, :run, :sent, :share)
|
|
74
|
+
private_constant :Touch
|
|
75
|
+
|
|
76
|
+
module_function
|
|
77
|
+
|
|
78
|
+
# @param conversation [Array<Hash>] the stored entries (symbol keys at
|
|
79
|
+
# the top, as Session.load has them)
|
|
80
|
+
# @param cwd [String, nil] the session's folder: a relative path's base
|
|
81
|
+
# @param strategy [Symbol, Array<Symbol>] :none or the layers, as
|
|
82
|
+
# LLMContextView takes it (the session's current one)
|
|
83
|
+
# @param stat [#call] File.stat, or a stand-in (specs)
|
|
84
|
+
# @return [Snapshot]
|
|
85
|
+
def build(conversation, cwd:, strategy:, stat: File.method(:stat))
|
|
86
|
+
conversation = Array(conversation)
|
|
87
|
+
view = LLMContextView.new(strategy: strategy)
|
|
88
|
+
stored = LLMContextStale.paired_runs(conversation)
|
|
89
|
+
sent = if view.none?
|
|
90
|
+
{}
|
|
91
|
+
else
|
|
92
|
+
LLMContextStale.paired_runs(view.messages(conversation)).to_h { |run| [run.ref.id, run.text] }
|
|
93
|
+
end
|
|
94
|
+
touches = stored.flat_map { |run| touches(run, sent.fetch(run.ref.id, run.text), cwd) }
|
|
95
|
+
items = touches.group_by { |touch| [touch.kind, touch.key] }.map do |(kind, key), group|
|
|
96
|
+
item(kind, key, group, conversation, view, stat)
|
|
97
|
+
end
|
|
98
|
+
Snapshot.new(items: items, at: conversation.size)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The items +run+ touched (none when it failed or was interrupted).
|
|
102
|
+
def touches(run, sent, cwd)
|
|
103
|
+
name = run.call[:name].to_s
|
|
104
|
+
return [] if failed?(run.text)
|
|
105
|
+
|
|
106
|
+
base = { run: run, sent: sent, content: CONTENT.include?(name), share: 1 }
|
|
107
|
+
if FILE_OPS.key?(name)
|
|
108
|
+
path = FileRef.for(name, run.call, cwd: cwd)&.path
|
|
109
|
+
path ? [Touch.new(kind: FILE, key: path, op: FILE_OPS[name], **base)] : []
|
|
110
|
+
elsif name == MEMORY_READ
|
|
111
|
+
names = found_names(run)
|
|
112
|
+
names.map { |memory| Touch.new(kind: MEMORY, key: memory, op: "read", **base.merge(share: names.size)) }
|
|
113
|
+
elsif name == MEMORY_WRITE
|
|
114
|
+
memory = run.call[:path].to_s.strip
|
|
115
|
+
memory.empty? ? [] : [Touch.new(kind: MEMORY, key: memory, op: "write", **base)]
|
|
116
|
+
elsif name == WEB_FETCH
|
|
117
|
+
url = run.call[:content].to_s.gsub(/\s+/, " ").strip
|
|
118
|
+
url.empty? ? [] : [Touch.new(kind: URL, key: url, op: "read", **base)]
|
|
119
|
+
else
|
|
120
|
+
[]
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# LLMContextStale's failed rule (an "Error" after the "[name]" lead; a
|
|
125
|
+
# denied call's "Error:" too), or an interrupted run.
|
|
126
|
+
def failed?(text)
|
|
127
|
+
ToolResponse.interrupted_text?(text) || text.sub(ToolResponse::LEAD, "").lstrip.start_with?("Error")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# A memory_read's names (Tools::MemoryRead.parse_names: none for the
|
|
131
|
+
# index listing), less the ones its output says weren't found.
|
|
132
|
+
def found_names(run)
|
|
133
|
+
names = Tools::MemoryRead.parse_names(run.call[:content].to_s)
|
|
134
|
+
last = run.text.split(ToolResponse::SEPARATOR).last.to_s
|
|
135
|
+
missing = (match = MISSING.match(last)) ? match[1].split(",").map(&:strip) : []
|
|
136
|
+
names - missing
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def item(kind, key, group, conversation, view, stat)
|
|
140
|
+
first = group.first
|
|
141
|
+
contents = group.select(&:content)
|
|
142
|
+
tokens = contents.sum { |touch| touch.sent.length / TokenUsage::CHARS_PER_TOKEN / touch.share }.ceil
|
|
143
|
+
state = if contents.any? && contents.none? { |touch| still_sent?(touch, conversation, view) }
|
|
144
|
+
FORGOTTEN
|
|
145
|
+
elsif kind == FILE && changed?(key, group.last.run, conversation, stat)
|
|
146
|
+
STALE
|
|
147
|
+
else
|
|
148
|
+
LIVE
|
|
149
|
+
end
|
|
150
|
+
Item.new(kind: kind, key: key, state: state, tokens: tokens, ops: group.map(&:op).uniq, count: group.size,
|
|
151
|
+
first_id: first.run.ref.id)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Whether the model is still sent +touch+'s output: whole (with the
|
|
155
|
+
# forget layer's "[#id] " after its lead, when it shows ids), or a
|
|
156
|
+
# stub that keeps some of it: a forget's kept lines, a stale stub an
|
|
157
|
+
# edit made (the model wrote the file).
|
|
158
|
+
def still_sent?(touch, conversation, view)
|
|
159
|
+
stored = touch.run.text
|
|
160
|
+
sent = touch.sent
|
|
161
|
+
return true if sent == stored || sent == with_id(stored, touch.run.ref.id)
|
|
162
|
+
|
|
163
|
+
edit = LLMContextEdit.on(conversation[touch.run.index])[touch.run.ref.id]
|
|
164
|
+
return true unless edit&.applied? && view.layers.include?(edit.kind)
|
|
165
|
+
|
|
166
|
+
edit.kind == :forget ? edit.keep? : edit.note.match?(BY_EDIT)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def with_id(text, id)
|
|
170
|
+
match = text.match(ToolResponse::LEAD)
|
|
171
|
+
match ? "#{match[0]}[##{id}] #{match.post_match}" : nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Whether the file at +path+ differs from +run+'s stamp (the item's
|
|
175
|
+
# latest run): no stamp, not changed.
|
|
176
|
+
def changed?(path, run, conversation, stat)
|
|
177
|
+
stamp = stamp_of(conversation[run.index], run.ref.run)
|
|
178
|
+
return false unless stamp
|
|
179
|
+
|
|
180
|
+
now = stat.call(path)
|
|
181
|
+
mtime_ns = (now.mtime.to_i * 1_000_000_000) + now.mtime.nsec
|
|
182
|
+
mtime_ns != field(stamp, :mtime_ns) || now.size != field(stamp, :size)
|
|
183
|
+
rescue SystemCallError
|
|
184
|
+
# Gone (or unreadable): not what the model saw.
|
|
185
|
+
true
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# +entry+'s tool_stamps at the run's place, a Hash with symbol keys
|
|
189
|
+
# (live) or string keys (after a reload); nil when none.
|
|
190
|
+
def stamp_of(entry, place)
|
|
191
|
+
stamps = entry[:tool_stamps]
|
|
192
|
+
stamp = stamps.is_a?(Array) ? stamps[place] : nil
|
|
193
|
+
stamp.is_a?(Hash) && field(stamp, :mtime_ns) ? stamp : nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def field(hash, key) = hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
197
|
+
|
|
198
|
+
private_class_method :touches, :failed?, :found_names, :item, :still_sent?, :with_id, :changed?, :stamp_of, :field
|
|
199
|
+
end
|
|
200
|
+
end
|