samagotchi 0.100.1 → 0.102.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 +50 -1
- data/bin/chi +14 -10
- data/docs/architecture.md +10 -2
- data/docs/cli.md +100 -5
- data/docs/configuration.md +26 -4
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -0
- data/docs/internals/llm-context-forget.md +14 -0
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/internals/working-set.md +205 -0
- data/docs/plugins.md +6 -3
- data/docs/sessions.md +28 -11
- data/docs/testing.md +8 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
- data/lib/samagotchi/bridge.rb +9 -3
- data/lib/samagotchi/bundle_command/check.rb +5 -1
- data/lib/samagotchi/bundle_command/try/command.rb +6 -6
- data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -4
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/commands/registry.rb +12 -1
- data/lib/samagotchi/config.rb +92 -53
- data/lib/samagotchi/engine.rb +153 -4
- data/lib/samagotchi/host_keys.rb +35 -18
- data/lib/samagotchi/idle_scheduler.rb +16 -1
- data/lib/samagotchi/llm_context_edit.rb +32 -10
- data/lib/samagotchi/llm_context_forget.rb +12 -3
- data/lib/samagotchi/llm_context_view.rb +19 -7
- data/lib/samagotchi/memory_bundle/installer.rb +2 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
- data/lib/samagotchi/message_parts.rb +8 -3
- data/lib/samagotchi/plugin/context.rb +2 -2
- data/lib/samagotchi/prompt.rb +1 -1
- data/lib/samagotchi/recap_command.rb +68 -0
- data/lib/samagotchi/recap_setup.rb +27 -14
- data/lib/samagotchi/send_command.rb +14 -11
- data/lib/samagotchi/session.rb +19 -12
- data/lib/samagotchi/session_commands.rb +44 -2
- data/lib/samagotchi/session_config.rb +411 -0
- data/lib/samagotchi/session_setup.rb +9 -9
- data/lib/samagotchi/sessions_command.rb +6 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
- data/lib/samagotchi/terminal_ui.rb +8 -6
- data/lib/samagotchi/testing/bundle.rb +3 -2
- 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 +76 -11
- data/lib/samagotchi/web/public/app.js +35 -3
- data/lib/samagotchi/web/public/chip_popover.js +56 -17
- data/lib/samagotchi/web/public/commands.js +8 -2
- data/lib/samagotchi/web/public/data.js +7 -0
- data/lib/samagotchi/web/public/index.html +145 -1
- 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/refs.js +10 -1
- 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 +10 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -0
- data/lib/samagotchi/web/public/turn_view.js +16 -4
- data/lib/samagotchi/web/public/working_set.js +612 -0
- data/lib/samagotchi/web/public/working_set_rail.js +278 -0
- data/lib/samagotchi/web/public/working_set_view.js +541 -0
- data/lib/samagotchi/web/server.rb +6 -4
- data/lib/samagotchi/worker.rb +9 -6
- data/lib/samagotchi/working_set.rb +238 -0
- data/lib/samagotchi/working_set_command.rb +119 -0
- data/lib/samagotchi/working_set_steer.rb +250 -0
- metadata +11 -2
- data/lib/samagotchi/bundle_session_settings.rb +0 -251
|
@@ -39,6 +39,7 @@ import * as model from "./turn_model.js";
|
|
|
39
39
|
import { createHold } from "./hold.js";
|
|
40
40
|
import { linkedLine } from "./notice_links.js";
|
|
41
41
|
import { taskCommand, taskStopButton } from "./task_stop.js";
|
|
42
|
+
import { footprintTemplate, groupItems, stampHistory } from "./working_set_view.js";
|
|
42
43
|
|
|
43
44
|
// ---- The templates: one step, its thinking, its label ----
|
|
44
45
|
|
|
@@ -840,8 +841,11 @@ function failedLineTemplate(record) {
|
|
|
840
841
|
// duration from the timing's tool records), then the answer and the turn's
|
|
841
842
|
// timing line under it.
|
|
842
843
|
// +thumbs(images)+ draws a prompt's or a tool row's image thumbs (a
|
|
843
|
-
// template, images.js thumbsTemplate).
|
|
844
|
-
|
|
844
|
+
// template, images.js thumbsTemplate). +workingSet+ (the stage view's
|
|
845
|
+
// web.working_set, never the turn view's): each block stamped with its
|
|
846
|
+
// turn's ordinal and its rows with theirs (working_set_view.js), and the
|
|
847
|
+
// turn's footprint line after it.
|
|
848
|
+
export function turnHistoryNodes(items, timing, { thumbs, workingSet = false }) {
|
|
845
849
|
const timingTemplate = (group) => {
|
|
846
850
|
const record = group.record;
|
|
847
851
|
// Numbered by its place in the turn records, as live (a failed turn
|
|
@@ -875,12 +879,16 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
|
|
|
875
879
|
};
|
|
876
880
|
const steerTemplate = (i, bubble) => steerRowTemplate({ source: items[i].source, text: items[i].content }, { bubble });
|
|
877
881
|
const parts = [];
|
|
878
|
-
// Each block's steps, in order: its outline once the nodes exist
|
|
882
|
+
// Each block's steps, in order: its outline once the nodes exist; and
|
|
883
|
+
// its turn's place among the turns (the working set's stamp).
|
|
879
884
|
const outlines = [];
|
|
885
|
+
const blockTurns = [];
|
|
886
|
+
let turnOrdinal = -1;
|
|
880
887
|
for (const group of turnGroups(items, timing)) {
|
|
881
888
|
if (group.kind === "note") { parts.push(noteBubbleTemplate(items[group.i])); continue; }
|
|
882
889
|
if (group.kind === "steer") { parts.push(steerTemplate(group.i, true)); continue; }
|
|
883
890
|
if (group.kind === "answer") { parts.push(answerTemplate(group.answer, nothing)); continue; }
|
|
891
|
+
turnOrdinal += 1;
|
|
884
892
|
const prompt = items[group.user];
|
|
885
893
|
// A context wake turn starts with its note.
|
|
886
894
|
parts.push(prompt.role === "note" ? noteBubbleTemplate(prompt) : userTemplate(prompt));
|
|
@@ -909,6 +917,8 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
|
|
|
909
917
|
}));
|
|
910
918
|
parts.push(html`<details class="turn-work done"><summary>${model.blockSummary({ gens, ended: true })}</summary>${steps}</details>`);
|
|
911
919
|
outlines.push(gens);
|
|
920
|
+
blockTurns.push(turnOrdinal);
|
|
921
|
+
if (workingSet) parts.push(footprintTemplate(groupItems(group, turnOrdinal), turnOrdinal));
|
|
912
922
|
}
|
|
913
923
|
// Steers with no step to sit in, or in a turn that shows no block: on
|
|
914
924
|
// their own after the prompt.
|
|
@@ -935,7 +945,9 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
|
|
|
935
945
|
}
|
|
936
946
|
const out = nodesOf(parts);
|
|
937
947
|
// A finished turn's block as the live end leaves it: its outline, open.
|
|
938
|
-
[...out.children].filter((el) => el.matches(".turn-work"))
|
|
948
|
+
const blocks = [...out.children].filter((el) => el.matches(".turn-work"));
|
|
949
|
+
blocks.forEach((block, i) => outlineBlock(block, outlines[i] || []));
|
|
950
|
+
if (workingSet) stampHistory(blocks, blockTurns);
|
|
939
951
|
// Each answer bubble and step text knows its message's refs
|
|
940
952
|
// (answer_refs.js; the page pairs them after the render), the index
|
|
941
953
|
// stays out of the DOM.
|
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
// The working set's pure parts (web.working_set, the stage view): what a
|
|
2
|
+
// turn brought into the model's context, from the tool rows the page
|
|
3
|
+
// already has, and how it folds into a few fixed lines. No DOM, so
|
|
4
|
+
// `node --test` covers the rules; working_set_rail.js and
|
|
5
|
+
// working_set_view.js draw them.
|
|
6
|
+
//
|
|
7
|
+
// An item is {kind, key, label, ops, at, count, state, path?, url?, ref?,
|
|
8
|
+
// preloaded?, muted?}: kind file|memory|url, key what it is (a file's full
|
|
9
|
+
// path, a memory's name, a URL), ops the operations on it (read, edit,
|
|
10
|
+
// write: a write of a file is an edit), at the first row that brought it in
|
|
11
|
+
// as ordinals ({turn, step, row}: the turn's place in the page's turns, the
|
|
12
|
+
// generation's in the turn, the row's in the generation; never a row key,
|
|
13
|
+
// which differs live and reloaded), count the rows that touched it. A later
|
|
14
|
+
// touch unions its ops and bumps its count, never its at: within a turn
|
|
15
|
+
// the set is append-only, which keeps the fold stable. state is "live" from
|
|
16
|
+
// the rows; the server's projection (GET working_set, :working_set_updated:
|
|
17
|
+
// WorkingSet) overlays its state (live, stale, forgotten, dropped) and
|
|
18
|
+
// tokens by key (overlay), never its at. The user steers it with
|
|
19
|
+
// `/working-set drop|undrop KEY…` lines (steerLine); one queued for after the
|
|
20
|
+
// running turn is pending (pendingSteers) until its command_ran.
|
|
21
|
+
|
|
22
|
+
export const FLASH_MS = 4000;
|
|
23
|
+
// Labels per form: the rail's four lines, the row's and the footprint's
|
|
24
|
+
// three, the collapsed head's newest one.
|
|
25
|
+
export const BUDGETS = Object.freeze({ rail: 4, row: 3, footprint: 3, head: 1 });
|
|
26
|
+
export const GLYPHS = Object.freeze({ file: "", memory: "◆", url: "↗", muted: "◇" });
|
|
27
|
+
// A directory label longer than this keeps its last two segments.
|
|
28
|
+
const DIR_MAX = 22;
|
|
29
|
+
|
|
30
|
+
const FILE_OPS = { read: "read", edit: "edit", write: "edit" };
|
|
31
|
+
const MEMORY_OPS = { memory_read: "read", memory_write: "write" };
|
|
32
|
+
|
|
33
|
+
// The names a memory row names: memory_read's title can list several
|
|
34
|
+
// ("a,b"; an empty one is an index listing: none), memory_write's is one.
|
|
35
|
+
export function memoryNames(title) {
|
|
36
|
+
return String(title || "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The URL in a web_fetch row's params (url="…", Ruby-inspect quoted, cut at
|
|
40
|
+
// 80 characters with a "…": the cut URL is kept as is), or null.
|
|
41
|
+
export function urlOfParams(params) {
|
|
42
|
+
const m = /(?:^|\s)url="((?:[^"\\]|\\.)*)"?/.exec(String(params || ""));
|
|
43
|
+
if (!m) return null;
|
|
44
|
+
const url = m[1].replace(/\\(.)/g, "$1");
|
|
45
|
+
return url || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A URL as a short label: its host, then its last two path segments behind
|
|
49
|
+
// "…/" when there are more ("github.com/…/pull/812").
|
|
50
|
+
export function urlLabel(url) {
|
|
51
|
+
const text = String(url || "");
|
|
52
|
+
const m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)([^?#]*)/i.exec(text);
|
|
53
|
+
if (!m) return text;
|
|
54
|
+
const segments = m[2].split("/").filter(Boolean);
|
|
55
|
+
if (segments.length <= 2) return [m[1], ...segments].join("/");
|
|
56
|
+
return `${m[1]}/…/${segments.slice(-2).join("/")}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function basename(path) {
|
|
60
|
+
const parts = String(path).split("/").filter(Boolean);
|
|
61
|
+
return parts.length ? parts[parts.length - 1] : String(path);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function dirOf(path) {
|
|
65
|
+
const parts = String(path).split("/");
|
|
66
|
+
parts.pop();
|
|
67
|
+
return parts.join("/");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A directory as a group's label: its path with a trailing "/" when it
|
|
71
|
+
// fits DIR_MAX, else "…/" and its last two segments ("…/web/public/").
|
|
72
|
+
export function dirLabel(dir) {
|
|
73
|
+
const clean = String(dir).replace(/\/+$/, "");
|
|
74
|
+
const full = `${clean}/`;
|
|
75
|
+
if (full.length <= DIR_MAX) return full;
|
|
76
|
+
const segments = clean.split("/").filter(Boolean);
|
|
77
|
+
return `…/${segments.slice(-2).join("/")}/`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// What one finished row brings into the set: [{kind, key, op, …}], none
|
|
81
|
+
// unless it completed ok.
|
|
82
|
+
export function rowEntries(row) {
|
|
83
|
+
if (!row || row.status !== "ok") return [];
|
|
84
|
+
const tool = row.tool;
|
|
85
|
+
if (FILE_OPS[tool]) {
|
|
86
|
+
const path = row.ref?.kind === "file" && row.ref.path ? row.ref.path : row.title;
|
|
87
|
+
if (!path) return [];
|
|
88
|
+
const ref = row.ref?.kind === "file" ? row.ref : null;
|
|
89
|
+
// Shown (and grouped) by the title when it is the path relative to the
|
|
90
|
+
// session's folder (the ref's path is absolute; a cut title starts "…").
|
|
91
|
+
const title = String(row.title || "");
|
|
92
|
+
const rel = title && !title.startsWith("\u2026") && String(path).endsWith(title) ? title : String(path);
|
|
93
|
+
return [{ kind: "file", key: String(path), op: FILE_OPS[tool], path: rel, ref }];
|
|
94
|
+
}
|
|
95
|
+
if (MEMORY_OPS[tool]) {
|
|
96
|
+
return memoryNames(row.title).map((name) => ({ kind: "memory", key: name, op: MEMORY_OPS[tool] }));
|
|
97
|
+
}
|
|
98
|
+
if (tool === "web_fetch") {
|
|
99
|
+
const url = urlOfParams(row.params);
|
|
100
|
+
return url ? [{ kind: "url", key: url, op: "read", url }] : [];
|
|
101
|
+
}
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A row's read lines ("1–120") when its ref names them.
|
|
106
|
+
function linesOf(ref) {
|
|
107
|
+
if (!ref?.line) return "";
|
|
108
|
+
return ref.end_line && ref.end_line !== ref.line ? `${ref.line}–${ref.end_line}` : String(ref.line);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function newItem(entry, at) {
|
|
112
|
+
const item = { kind: entry.kind, key: entry.key, label: leafText(entry), ops: [entry.op], at, count: 1, state: "live" };
|
|
113
|
+
if (entry.path) item.path = entry.path;
|
|
114
|
+
if (entry.url) item.url = entry.url;
|
|
115
|
+
if (entry.ref) item.ref = entry.ref;
|
|
116
|
+
const lines = entry.op === "read" ? linesOf(entry.ref) : "";
|
|
117
|
+
if (lines) item.lines = lines;
|
|
118
|
+
return item;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function touch(item, op) {
|
|
122
|
+
if (!item.ops.includes(op)) item.ops = [...item.ops, op];
|
|
123
|
+
item.count += 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A turn's items ({gens: [{tools: rows}]}, the live turn's model or a
|
|
127
|
+
// reloaded turn's steps as gens), in order of first entry; +turnOrdinal+
|
|
128
|
+
// is the turn's place in the page's turns.
|
|
129
|
+
export function itemsOfTurn(turn, turnOrdinal) {
|
|
130
|
+
const items = [];
|
|
131
|
+
const byKey = new Map();
|
|
132
|
+
(turn?.gens || []).forEach((gen, step) => {
|
|
133
|
+
(gen?.tools || []).forEach((row, rowIndex) => {
|
|
134
|
+
for (const entry of rowEntries(row)) {
|
|
135
|
+
const id = `${entry.kind}:${entry.key}`;
|
|
136
|
+
const found = byKey.get(id);
|
|
137
|
+
if (found) touch(found, entry.op);
|
|
138
|
+
else {
|
|
139
|
+
const item = newItem(entry, { turn: turnOrdinal, step, row: rowIndex });
|
|
140
|
+
byKey.set(id, item);
|
|
141
|
+
items.push(item);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
return items;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The session's set: every turn's items merged (the first entry's at kept,
|
|
150
|
+
// ops unioned, counts summed), the preloaded memories first (tag
|
|
151
|
+
// "prompt"), the used memories no row explains (a turn without its saved
|
|
152
|
+
// rows) after them; the muted memories listed apart, never counted.
|
|
153
|
+
// @param turns the finished turns' item lists, in order
|
|
154
|
+
// @param live the running turn's items (or none)
|
|
155
|
+
export function sessionItems({ turns = [], live = [], preloaded = [], muted = [], used = [] } = {}) {
|
|
156
|
+
const items = [];
|
|
157
|
+
const byKey = new Map();
|
|
158
|
+
for (const name of preloaded || []) {
|
|
159
|
+
if (byKey.has(`memory:${name}`)) continue;
|
|
160
|
+
const item = { kind: "memory", key: name, label: name, ops: [], at: null, count: 0, state: "live", preloaded: true };
|
|
161
|
+
byKey.set(`memory:${name}`, item);
|
|
162
|
+
items.push(item);
|
|
163
|
+
}
|
|
164
|
+
for (const list of [...turns, live || []]) {
|
|
165
|
+
for (const it of list) {
|
|
166
|
+
const id = `${it.kind}:${it.key}`;
|
|
167
|
+
const found = byKey.get(id);
|
|
168
|
+
if (!found) {
|
|
169
|
+
const copy = { ...it, ops: [...it.ops] };
|
|
170
|
+
byKey.set(id, copy);
|
|
171
|
+
items.push(copy);
|
|
172
|
+
} else {
|
|
173
|
+
for (const op of it.ops) if (!found.ops.includes(op)) found.ops = [...found.ops, op];
|
|
174
|
+
found.count += it.count;
|
|
175
|
+
if (!found.at) found.at = it.at;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const name of used || []) {
|
|
180
|
+
if (byKey.has(`memory:${name}`)) continue;
|
|
181
|
+
const item = { kind: "memory", key: name, label: name, ops: ["read"], at: null, count: 0, state: "live" };
|
|
182
|
+
byKey.set(`memory:${name}`, item);
|
|
183
|
+
items.push(item);
|
|
184
|
+
}
|
|
185
|
+
const mutedItems = (muted || []).map((name) => ({ kind: "memory", key: name, label: name, ops: [], at: null, count: 0, state: "live", muted: true }));
|
|
186
|
+
return { items, muted: mutedItems };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── the server's truth ────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
export const STATES = Object.freeze(["live", "stale", "forgotten", "dropped"]);
|
|
192
|
+
const ELLIPSIS = "\u2026";
|
|
193
|
+
|
|
194
|
+
// The server's items by "kind:key", and its URLs (a cut row key matches one
|
|
195
|
+
// by prefix).
|
|
196
|
+
function serverIndex(server) {
|
|
197
|
+
const byKey = new Map();
|
|
198
|
+
const urls = [];
|
|
199
|
+
for (const it of server?.items || []) {
|
|
200
|
+
if (!it || !STATES.includes(it.state)) continue;
|
|
201
|
+
byKey.set(`${it.kind}:${it.key}`, it);
|
|
202
|
+
if (it.kind === "url") urls.push(it);
|
|
203
|
+
}
|
|
204
|
+
return { byKey, urls };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The server's item for +item+: files by absolute path (a title fallback
|
|
208
|
+
// won't match), memories by name, URLs by the row's key (its params
|
|
209
|
+
// preview: cut at 80 characters with a "…", whose head must start the
|
|
210
|
+
// server's URL). null when the server doesn't know it (a running call).
|
|
211
|
+
function serverItem(index, item) {
|
|
212
|
+
const exact = index.byKey.get(`${item.kind}:${item.key}`);
|
|
213
|
+
if (exact || item.kind !== "url") return exact || null;
|
|
214
|
+
const key = String(item.key);
|
|
215
|
+
if (!key.endsWith(ELLIPSIS)) return null;
|
|
216
|
+
const head = key.slice(0, -1);
|
|
217
|
+
return index.urls.find((it) => String(it.key).startsWith(head)) || null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// +items+ with the server's state and tokens (+server+: a snapshot {items,
|
|
221
|
+
// at}, or null); an item it doesn't know stays live, without tokens. New
|
|
222
|
+
// objects only where something changed; never touches at, ops or count.
|
|
223
|
+
export function overlay(items, server) {
|
|
224
|
+
if (!server) return items;
|
|
225
|
+
const index = serverIndex(server);
|
|
226
|
+
return items.map((item) => {
|
|
227
|
+
const found = serverItem(index, item);
|
|
228
|
+
const state = found ? found.state : "live";
|
|
229
|
+
const tokens = found && Number.isFinite(found.tokens) ? found.tokens : undefined;
|
|
230
|
+
// A cut URL row's full key, the one a steer line names.
|
|
231
|
+
const serverKey = found && found.key !== item.key ? found.key : undefined;
|
|
232
|
+
if (item.state === state && item.tokens === tokens && item.serverKey === serverKey) return item;
|
|
233
|
+
const next = { ...item, state };
|
|
234
|
+
if (tokens === undefined) delete next.tokens;
|
|
235
|
+
else next.tokens = tokens;
|
|
236
|
+
if (serverKey === undefined) delete next.serverKey;
|
|
237
|
+
else next.serverKey = serverKey;
|
|
238
|
+
return next;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Which snapshot to keep, +next+ or +kept+. Events come in the order the
|
|
243
|
+
// server built them, so an event (+event+) always wins: a smaller at is a
|
|
244
|
+
// rollback that shortened the conversation, not an older set. A GET's
|
|
245
|
+
// answer loses to an event that came while it was on its way (+overtaken+),
|
|
246
|
+
// and, asked while a turn runs (its running: it is built from the session's
|
|
247
|
+
// messages, behind that turn's events), to a kept one from more of the
|
|
248
|
+
// conversation (its at).
|
|
249
|
+
export function newerSnapshot(kept, next, { event = false, overtaken = false } = {}) {
|
|
250
|
+
if (!next || !Array.isArray(next.items) || !Number.isFinite(next.at)) return kept;
|
|
251
|
+
if (!kept || event) return next;
|
|
252
|
+
if (overtaken) return kept;
|
|
253
|
+
if (next.running && next.at < kept.at) return kept;
|
|
254
|
+
return next;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Tokens as the popover shows them: "~840", "~1.2k", "~12k".
|
|
258
|
+
export function tokensText(tokens) {
|
|
259
|
+
if (!Number.isFinite(tokens)) return "";
|
|
260
|
+
if (tokens < 1000) return `~${Math.round(tokens)}`;
|
|
261
|
+
const k = tokens / 1000;
|
|
262
|
+
return `~${k < 10 ? String(Math.round(k * 10) / 10).replace(/\.0$/, "") : Math.round(k)}k`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const isForgotten = (item) => item.state === "forgotten";
|
|
266
|
+
// Nothing of it is sent: the model forgot it, or the user dropped it.
|
|
267
|
+
const isStruck = (item) => item.state === "forgotten" || item.state === "dropped";
|
|
268
|
+
const isFaded = (item) => item.state === "stale" || isStruck(item);
|
|
269
|
+
|
|
270
|
+
// A group's state: dropped when all its members are, forgotten when all are
|
|
271
|
+
// struck (forgotten or dropped), stale when all are stale or struck, else
|
|
272
|
+
// live (spec §3: struck only when all forgotten, faded when all stale or
|
|
273
|
+
// forgotten).
|
|
274
|
+
export function groupState(members) {
|
|
275
|
+
if (members.length && members.every((m) => m.state === "dropped")) return "dropped";
|
|
276
|
+
if (members.length && members.every(isStruck)) return "forgotten";
|
|
277
|
+
if (members.length && members.every(isFaded)) return "stale";
|
|
278
|
+
return "live";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function edited(item) {
|
|
282
|
+
return item.kind === "file" && item.ops.includes("edit");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A leaf's text: a file's name, a memory's name, a URL's short label.
|
|
286
|
+
export function leafText(item) {
|
|
287
|
+
if (item.kind === "file") return basename(item.path || item.key);
|
|
288
|
+
if (item.kind === "url") return urlLabel(item.url || item.key);
|
|
289
|
+
return String(item.key);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── the fold ──────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
function leafLabel(item) {
|
|
295
|
+
return {
|
|
296
|
+
id: `${item.kind}:${item.key}`, type: "leaf", kind: item.kind, glyph: GLYPHS[item.kind] || "", text: leafText(item),
|
|
297
|
+
n: 1, edited: edited(item) ? 1 : 0, cut: isStruck(item) ? 1 : 0, state: item.state || "live", members: [item],
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function plural(n, word) {
|
|
302
|
+
if (n === 1) return `${n} ${word}`;
|
|
303
|
+
return `${n} ${word === "memory" ? "memories" : `${word}s`}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const KIND_WORDS = { memory: "memory", url: "url", file: "file" };
|
|
307
|
+
|
|
308
|
+
// A group spec as a label over +members+ (items, in entry order).
|
|
309
|
+
function groupLabel(spec, members) {
|
|
310
|
+
const n = members.length;
|
|
311
|
+
const edits = members.filter(edited).length;
|
|
312
|
+
// The state marks (spec §3): ✂n the forgotten members, the group's own
|
|
313
|
+
// state from all of them.
|
|
314
|
+
const marks = { cut: members.filter(isStruck).length, state: groupState(members) };
|
|
315
|
+
if (spec.type === "dir") {
|
|
316
|
+
return { id: `dir:${spec.dir}`, type: "dir", kind: "file", glyph: "", text: dirLabel(spec.dir), count: plural(n, "file"), n, edited: edits, ...marks, members, dir: spec.dir };
|
|
317
|
+
}
|
|
318
|
+
if (spec.type === "kind") {
|
|
319
|
+
return { id: `kind:${spec.of}`, type: "kind", kind: spec.of, glyph: GLYPHS[spec.of], text: plural(n, KIND_WORDS[spec.of]), n, edited: 0, ...marks, members };
|
|
320
|
+
}
|
|
321
|
+
const dirs = new Set(members.map((m) => dirOf(m.path || m.key)));
|
|
322
|
+
return { id: "files", type: "files", kind: "file", glyph: "", text: `${plural(n, "file")} · ${plural(dirs.size, "dir")}`, n, edited: edits, ...marks, members };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Whether label +l+ belongs to group +spec+.
|
|
326
|
+
function belongs(l, spec) {
|
|
327
|
+
if (spec.type === "dir") return l.type === "leaf" && l.kind === "file" && (l.members[0].path || l.members[0].key).startsWith(`${spec.dir}/`);
|
|
328
|
+
if (spec.type === "kind") return l.type === "leaf" && l.kind === spec.of;
|
|
329
|
+
return l.kind === "file" && (l.type === "leaf" || l.type === "dir");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// +labels+ with +spec+'s members folded into one label at the first one's
|
|
333
|
+
// slot, or null when it would hold fewer than +least+.
|
|
334
|
+
function applyGroup(labels, spec, least = 2) {
|
|
335
|
+
const at = [];
|
|
336
|
+
labels.forEach((l, i) => { if (belongs(l, spec)) at.push(i); });
|
|
337
|
+
if (at.length < least) return null;
|
|
338
|
+
const members = at.flatMap((i) => labels[i].members);
|
|
339
|
+
const group = groupLabel(spec, members);
|
|
340
|
+
const out = [];
|
|
341
|
+
labels.forEach((l, i) => {
|
|
342
|
+
if (i === at[0]) out.push(group);
|
|
343
|
+
else if (!at.includes(i)) out.push(l);
|
|
344
|
+
});
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// The folders, in order: each looks at the labels and names the group it
|
|
349
|
+
// would fold, or null. Later rules are new entries here, not loop changes.
|
|
350
|
+
function byDirectory(labels) {
|
|
351
|
+
const counts = new Map();
|
|
352
|
+
for (const l of labels) {
|
|
353
|
+
if (l.type !== "leaf" || l.kind !== "file") continue;
|
|
354
|
+
const parts = String(l.members[0].path || l.members[0].key).split("/");
|
|
355
|
+
parts.pop();
|
|
356
|
+
for (let d = parts.length; d > 0; d -= 1) {
|
|
357
|
+
const dir = parts.slice(0, d).join("/");
|
|
358
|
+
if (!dir) continue;
|
|
359
|
+
counts.set(dir, (counts.get(dir) || 0) + 1);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// The deepest directory holding two or more (ties: the one holding more,
|
|
363
|
+
// then the earlier), so web/public/ and lib/samagotchi/llm/ fold apart
|
|
364
|
+
// instead of into lib/samagotchi/.
|
|
365
|
+
let best = null;
|
|
366
|
+
for (const [dir, n] of counts) {
|
|
367
|
+
if (n < 2) continue;
|
|
368
|
+
const depth = dir.split("/").length;
|
|
369
|
+
if (!best || depth > best.depth || (depth === best.depth && n > best.n)) best = { dir, depth, n };
|
|
370
|
+
}
|
|
371
|
+
return best ? { type: "dir", dir: best.dir } : null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function byKind(labels) {
|
|
375
|
+
for (const of of ["memory", "url"]) {
|
|
376
|
+
if (labels.filter((l) => l.type === "leaf" && l.kind === of).length >= 2) return { type: "kind", of };
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function lastResort(labels) {
|
|
382
|
+
return labels.filter((l) => l.kind === "file" && (l.type === "leaf" || l.type === "dir")).length >= 2 ? { type: "files" } : null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const FOLDERS = [byDirectory, byKind, lastResort];
|
|
386
|
+
|
|
387
|
+
function specKey(spec) {
|
|
388
|
+
return spec.type === "dir" ? `dir:${spec.dir}` : spec.type === "kind" ? `kind:${spec.of}` : "files";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// +items+ (in entry order) as at most +budget+ labels: leaves while they
|
|
392
|
+
// fit, else the folders' groups, each at its first member's slot. +kept+:
|
|
393
|
+
// the groups an earlier fold of the same set made (its +groups+): applied
|
|
394
|
+
// first, so a group once folded stays (the set only grows). +lastResort+:
|
|
395
|
+
// false leaves the "N files · K dirs" rule out (the footprint's tails say
|
|
396
|
+
// the rest instead). @return {labels, groups}
|
|
397
|
+
export function fold(items, budget, { kept = [], lastResort: useLast = true } = {}) {
|
|
398
|
+
let labels = items.map(leafLabel);
|
|
399
|
+
const groups = [];
|
|
400
|
+
const done = new Set();
|
|
401
|
+
for (const spec of kept) {
|
|
402
|
+
if (done.has(specKey(spec))) continue;
|
|
403
|
+
const next = applyGroup(labels, spec, 1);
|
|
404
|
+
if (next) {
|
|
405
|
+
labels = next;
|
|
406
|
+
groups.push(spec);
|
|
407
|
+
done.add(specKey(spec));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const folders = useLast ? FOLDERS : FOLDERS.filter((f) => f !== lastResort);
|
|
411
|
+
while (labels.length > budget) {
|
|
412
|
+
const spec = folders.map((f) => f(labels)).find(Boolean);
|
|
413
|
+
const next = spec && applyGroup(labels, spec);
|
|
414
|
+
if (!next) break;
|
|
415
|
+
labels = next;
|
|
416
|
+
groups.push(spec);
|
|
417
|
+
}
|
|
418
|
+
return { labels, groups };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// The labels a form shows for +budget+: all of them when they fit, else
|
|
422
|
+
// the first budget − 1 and how many members the rest hold (its "+ n more").
|
|
423
|
+
export function shown(labels, budget) {
|
|
424
|
+
if (labels.length <= budget) return { labels, more: 0 };
|
|
425
|
+
const head = labels.slice(0, Math.max(0, budget - 1));
|
|
426
|
+
return { labels: head, more: labels.slice(head.length).reduce((n, l) => n + l.n, 0) };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// A label's hover: its members, one per line ("path ✎").
|
|
430
|
+
export function labelTitle(label) {
|
|
431
|
+
return label.members.map((m) => `${m.kind === "file" ? (m.path || m.key) : m.kind === "url" ? (m.url || m.key) : m.key}${edited(m) ? " ✎" : ""}${isFaded(m) ? ` (${m.state})` : ""}`).join("\n");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── tags, texts ───────────────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
// Where an item entered: "s3" (step 3 of its turn), "t2·s3" in the
|
|
437
|
+
// session's scope, "prompt" for a preloaded memory, "" when unknown.
|
|
438
|
+
export function stepTag(item, { session = false } = {}) {
|
|
439
|
+
if (item?.preloaded) return "prompt";
|
|
440
|
+
const at = item?.at;
|
|
441
|
+
if (!at) return "";
|
|
442
|
+
return session ? `t${at.turn + 1}·s${at.step + 1}` : `s${at.step + 1}`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// A group's steps as a range ("s1–5"), or its one step ("s2").
|
|
446
|
+
export function stepRange(members) {
|
|
447
|
+
const steps = [...new Set(members.filter((m) => m.at).map((m) => m.at.step + 1))].sort((a, b) => a - b);
|
|
448
|
+
if (!steps.length) return "";
|
|
449
|
+
return steps.length === 1 ? `s${steps[0]}` : `s${steps[0]}–${steps[steps.length - 1]}`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// What was done to an item, in the expanded view's words.
|
|
453
|
+
export function opsText(item) {
|
|
454
|
+
if (item.muted) return "muted (--mute)";
|
|
455
|
+
if (item.preloaded) return item.ops.length ? "preloaded, read" : "preloaded";
|
|
456
|
+
const words = [];
|
|
457
|
+
if (item.ops.includes("read")) {
|
|
458
|
+
const reads = item.ops.length === 1 && item.count > 1 ? ` ×${item.count}` : item.lines ? ` ${item.lines}` : "";
|
|
459
|
+
words.push(item.kind === "url" ? "fetched" : `read${reads}`);
|
|
460
|
+
}
|
|
461
|
+
if (item.ops.includes("edit")) words.push(item.ops.length === 1 && item.count > 1 ? `edited ×${item.count}` : "edited");
|
|
462
|
+
if (item.ops.includes("write")) words.push("written");
|
|
463
|
+
const text = words.join(", ");
|
|
464
|
+
// The state in words (colour is never alone): "read 1–120 · stale".
|
|
465
|
+
return isFaded(item) ? [text, item.state].filter(Boolean).join(" · ") : text;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// A label as the screen reader says it ("web/public 9 files 2 edited").
|
|
469
|
+
function spoken(label) {
|
|
470
|
+
if (label.type === "dir") return `${label.text.replace(/^…\//, "").replace(/\/$/, "")} ${label.count}${label.edited ? ` ${label.edited} edited` : ""}${cutWords(label)}`;
|
|
471
|
+
if (label.type === "leaf") return `${label.kind === "memory" ? "memory " : label.kind === "url" ? "url " : ""}${label.text}${label.edited ? " edited" : ""}${label.state && label.state !== "live" ? ` ${label.state}` : ""}`;
|
|
472
|
+
return `${label.text}${cutWords(label)}`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// A group's struck members in words: " 2 forgotten, 1 dropped".
|
|
476
|
+
function cutWords(label) {
|
|
477
|
+
const dropped = label.members.filter((m) => m.state === "dropped").length;
|
|
478
|
+
const forgotten = label.cut - dropped;
|
|
479
|
+
return [forgotten ? ` ${forgotten} forgotten` : "", dropped ? ` ${dropped} dropped` : ""].filter(Boolean).join(",");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// The rail's (or row's, footprint's) aria-label: the folded text spoken.
|
|
483
|
+
export function ariaLabel(labels, more = 0) {
|
|
484
|
+
if (!labels.length) return "Working set: nothing yet. Open the list.";
|
|
485
|
+
return `Working set: ${labels.map(spoken).join(", ")}${more ? `, and ${more} more` : ""}. Open the list.`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// The footprint's pieces for a finished turn's +items+: its labels (at
|
|
489
|
+
// most +budget+, no last resort), the rest as one tail ("+1 url", "+3
|
|
490
|
+
// more"), and how many files it edited. null for a turn that touched
|
|
491
|
+
// nothing (no line).
|
|
492
|
+
export function footprint(items, budget = BUDGETS.footprint) {
|
|
493
|
+
if (!items.length) return null;
|
|
494
|
+
const { labels } = fold(items, budget, { lastResort: false });
|
|
495
|
+
const head = labels.slice(0, budget);
|
|
496
|
+
const rest = labels.slice(budget).flatMap((l) => l.members);
|
|
497
|
+
let tail = "";
|
|
498
|
+
if (rest.length) {
|
|
499
|
+
const kinds = new Set(rest.map((m) => m.kind));
|
|
500
|
+
tail = kinds.size === 1 ? `+${plural(rest.length, KIND_WORDS[[...kinds][0]])}` : `+${rest.length} more`;
|
|
501
|
+
}
|
|
502
|
+
return { labels: head, tail, edited: items.filter(edited).length, forgotten: items.filter(isForgotten).length,
|
|
503
|
+
dropped: items.filter((i) => i.state === "dropped").length };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// A label's text as the footprint and the row draw it: its glyph, text,
|
|
507
|
+
// count ("web/public/ · 9 files").
|
|
508
|
+
export function labelText(label) {
|
|
509
|
+
const glyph = label.glyph ? `${label.glyph} ` : "";
|
|
510
|
+
return `${glyph}${label.text}${label.count ? ` · ${label.count}` : ""}`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// The footprint as plain text (its title, the specs): "touched web/public/
|
|
514
|
+
// · 9 files · ◆ 3 memories s1–5 · +1 url · edited 2".
|
|
515
|
+
export function footprintText(items, budget = BUDGETS.footprint) {
|
|
516
|
+
const fp = footprint(items, budget);
|
|
517
|
+
if (!fp) return "";
|
|
518
|
+
const parts = fp.labels.map((l) => {
|
|
519
|
+
const tag = l.kind === "memory" ? (l.type === "leaf" ? stepTag(l.members[0]) : stepRange(l.members)) : "";
|
|
520
|
+
return `${labelText(l)}${tag ? ` ${tag}` : ""}`;
|
|
521
|
+
});
|
|
522
|
+
if (fp.tail) parts.push(fp.tail);
|
|
523
|
+
if (fp.edited) parts.push(`edited ${fp.edited}`);
|
|
524
|
+
if (fp.forgotten) parts.push(`${fp.forgotten} forgotten`);
|
|
525
|
+
if (fp.dropped) parts.push(`${fp.dropped} dropped`);
|
|
526
|
+
return `touched ${parts.join(" · ")}`;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── the fresh cue ─────────────────────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
// An item seen first at +seenAt+ (ms) is fresh until FLASH_MS after it.
|
|
532
|
+
export function isFresh(seenAt, now, ms = FLASH_MS) {
|
|
533
|
+
return Number.isFinite(seenAt) && now - seenAt < ms;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// How long until the next fresh cue ends (ms), null when none is on.
|
|
537
|
+
export function nextSettle(seenTimes, now, ms = FLASH_MS) {
|
|
538
|
+
let next = null;
|
|
539
|
+
for (const t of seenTimes) {
|
|
540
|
+
if (!isFresh(t, now, ms)) continue;
|
|
541
|
+
const left = t + ms - now;
|
|
542
|
+
if (next === null || left < next) next = left;
|
|
543
|
+
}
|
|
544
|
+
return next;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ── steering (drop / undrop) ──────────────────────────────────────────────
|
|
548
|
+
|
|
549
|
+
export const STEER_COMMAND = "/working-set";
|
|
550
|
+
const STEER_LINE = /^\/working-set\s+(drop|undrop)\s+(\S.*)$/;
|
|
551
|
+
const URL_WORD = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
552
|
+
export const INVERSE = Object.freeze({ drop: "undrop", undrop: "drop" });
|
|
553
|
+
|
|
554
|
+
// The word a steer line names +item+ by (WorkingSetSteer.target): a file's
|
|
555
|
+
// absolute path, memory:NAME, a URL (the server's full one for a cut row).
|
|
556
|
+
export function steerWord(item) {
|
|
557
|
+
if (item.kind === "memory") return `memory:${item.key}`;
|
|
558
|
+
if (item.kind === "url") return item.serverKey || item.key;
|
|
559
|
+
return item.key;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// The id the page keys an item by ("file:/abs/a.rb"), from a steer word.
|
|
563
|
+
function wordId(word) {
|
|
564
|
+
if (word.startsWith("memory:")) return `memory:${word.slice("memory:".length)}`;
|
|
565
|
+
if (URL_WORD.test(word)) return `url:${word}`;
|
|
566
|
+
return `file:${word}`;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// An item's id as steer lines name it.
|
|
570
|
+
export function steerId(item) {
|
|
571
|
+
return wordId(steerWord(item));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// The line that does +op+ to +items+ ("/working-set drop /abs/a.rb memory:x").
|
|
575
|
+
export function steerLine(op, items) {
|
|
576
|
+
return `${STEER_COMMAND} ${op} ${items.map(steerWord).join(" ")}`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// A steer line's op and ids ({op, ids}), or null for any other line.
|
|
580
|
+
export function parseSteerLine(line) {
|
|
581
|
+
const m = STEER_LINE.exec(String(line || "").trim());
|
|
582
|
+
if (!m) return null;
|
|
583
|
+
return { op: m[1], ids: m[2].split(/\s+/).filter(Boolean).map(wordId) };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// The ops queued for after the running turn (+lines+, in queue order), per
|
|
587
|
+
// item id: "drop" or "undrop". An op followed by its inverse nets to
|
|
588
|
+
// nothing (the worker runs both, the second undoing the first).
|
|
589
|
+
export function pendingSteers(lines) {
|
|
590
|
+
const pending = new Map();
|
|
591
|
+
for (const line of lines) {
|
|
592
|
+
const parsed = parseSteerLine(line);
|
|
593
|
+
if (!parsed) continue;
|
|
594
|
+
for (const id of parsed.ids) {
|
|
595
|
+
if (pending.get(id) === INVERSE[parsed.op]) pending.delete(id);
|
|
596
|
+
else pending.set(id, parsed.op);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return pending;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Why +item+ can't be dropped (a title for its disabled button), "" when
|
|
603
|
+
// it can, null when it has no drop at all (muted, dropped already, or the
|
|
604
|
+
// server doesn't know it yet: a running call).
|
|
605
|
+
export function dropRefusal(item) {
|
|
606
|
+
if (item.muted || item.state === "dropped") return null;
|
|
607
|
+
if (item.preloaded && !item.ops.includes("read")) return "preloaded into the system prompt: not dropped from here";
|
|
608
|
+
if (!Number.isFinite(item.tokens)) return null;
|
|
609
|
+
if (!item.ops.includes("read")) return "only edited or written: nothing of it to drop";
|
|
610
|
+
if (item.state === "forgotten") return "forgotten: nothing of it is sent";
|
|
611
|
+
return "";
|
|
612
|
+
}
|