@uehaj/sys1grep 0.5.0-next.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/serve.mjs ADDED
@@ -0,0 +1,1121 @@
1
+ // sys1grep --serve [PORT] (#166): a page with a search box on 127.0.0.1. Each search runs sys1grep itself as a child,
2
+ // with the launch arguments plus what the page's controls changed, so the page cannot do what the command line cannot.
3
+ import { createServer } from 'node:http';
4
+ import { execFile, spawnSync } from 'node:child_process';
5
+ import { parseArgs, parseEnv } from 'node:util';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { readFileSync, statSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { FIELDS, maskOpts, maskUrl, readSettings, writeSettings } from './settings.mjs';
11
+ import { OPTIONS, REVERSE_REMOVED, fill, parseOpts } from './options.mjs';
12
+
13
+ // The settings panel's view of ~/.config/sys1grep/settings.json, one tuple per field: [the saved value (for a key, only
14
+ // whether one is saved; for url and opts, with a --sys1-api-key or a url's userinfo masked, the same way --verbose
15
+ // masks them), where the value a search uses comes from: cmd | env | settings | .env | default, the variable when
16
+ // env]. It follows sys1grep's own order (command line, environment, settings.json, .env), so the page can say when
17
+ // something it saves would not take effect.
18
+ const KEYS = new Set(['key', 'summarizerKey']);
19
+ const settingsView = (cli, launch = []) => {
20
+ const s = readSettings() ?? {};
21
+ let dotenv = {};
22
+ try { dotenv = parseEnv(readFileSync(`${homedir()}/.config/sys1grep/.env`, 'utf8')); } catch {}
23
+ return { ...Object.fromEntries(Object.entries(FIELDS).map(([f, v]) => {
24
+ const names = [`SYS1GREP_${v}`, `SEMGREP_${v}`, ...(f === 'key' ? ['TYPESAFE_API_KEY'] : [])];
25
+ const env = names.find(n => process.env[n] !== undefined && (f === 'opts' || process.env[n] !== ''));
26
+ const saved = KEYS.has(f) ? s[f] !== undefined : f === 'opts' ? (s.opts ? maskOpts(s.opts) : null) : f === 'url' ? (s.url ? maskUrl(s.url) : null) : s[f] ?? null;
27
+ const src = cli[f] !== undefined ? ['cmd'] : env ? ['env', env] : s[f] !== undefined ? ['settings'] : names.some(n => dotenv[n] !== undefined) ? ['.env'] : ['default'];
28
+ return [f, [saved, ...src]];
29
+ })), by: summarizerOf(s, dotenv, launch) };
30
+ };
31
+ // The page's "summarize uses ..." note: the TOOL and model the summarize step runs, in sys1grep's order (a launch
32
+ // --summarize=TOOL, the environment, settings.json, ~/.config/sys1grep/.env, then claude with haiku).
33
+ // ponytail: a --summarize=TOOL inside SYS1GREP_OPTS / settings.json opts is not read; add it if someone sets one there.
34
+ const summarizerOf = (s, dotenv, launch) => {
35
+ const pick = (v, f) => [`SYS1GREP_${v}`, `SEMGREP_${v}`].map(n => process.env[n]).find(x => x) ?? s[f] ?? dotenv[`SYS1GREP_${v}`];
36
+ const tool = launch.findLast(a => /^--summarize=./.test(a) && a !== '--summarize=ask')?.slice(12) ?? pick('SUMMARIZER', 'summarizer') ?? 'claude';
37
+ const shown = /^https?:/.test(tool) ? maskUrl(tool) : tool, noDefault = /^https?:|^(ollama|lmstudio)$/.test(tool); // sys1grep.mjs dies without a model for these
38
+ const model = pick('SUMMARIZER_MODEL', 'summarizerModel') ?? (tool === 'claude' ? 'haiku' : noDefault ? 'not set (required)' : `${shown}'s default`);
39
+ return `summarize uses ${shown}, model ${model}`;
40
+ };
41
+ // --serve itself refuses REFUSED_LAUNCH (below) at launch: the page replaces these, or their meaning comes from the
42
+ // page. The settings panel's opts run through sys1grep.mjs's own parseOpts (options.mjs, shared: #199 — three
43
+ // rounds of regex/cluster checks each missed a form parseArgs itself already handles correctly, from -nr to -rC1
44
+ // to --r), which refuses a positional (a target, #196) and a meaning/expression on its own; DENY below refuses the
45
+ // rest, by the canonical OPTIONS name parseOpts resolves a token to, however it was spelled (-r, --r, or inside a
46
+ // cluster like -rC1 or -nr all resolve to "r").
47
+ // Checked on parseArgs' own tokens: a regex over the raw arguments missed a cluster like -nq and a long name like
48
+ // --with-filename or --l. A negation (--no-verbose) only turns one off.
49
+ const REFUSED_LAUNCH = new Set(['e', 'P', 'a', 'v', 'question', 'l', 'c', 'quiet', 'o', 'z', 'interactive', 'with-filename',
50
+ 'help', 'version', 'step-to', 'step-reverse-to', 'format', 'color', 'verbose', 'dry-run', 'install-templates']);
51
+ // --unit=zero is -z by another name (options.mjs): REFUSED_LAUNCH names options, so it cannot single it out from every
52
+ // other --unit value, and --unit also takes its value split ("--unit zero"), so this follows parseArgs' own rule
53
+ // (string option, no short form: the last --unit/--unit= wins) to find what --unit actually resolves to at launch.
54
+ const valueAt = (launch, name) => {
55
+ let v;
56
+ for (let i = 0; i < launch.length; i++) {
57
+ if (launch[i] === `--${name}`) v = launch[++i];
58
+ else if (launch[i].startsWith(`--${name}=`)) v = launch[i].slice(name.length + 3);
59
+ }
60
+ return v;
61
+ };
62
+ const unitAt = launch => valueAt(launch, 'unit');
63
+ // --lang=en|ja (#270): the launch's own default for the page (and for every search it runs, harmlessly: it only
64
+ // changes --help's language). Read, never lift()ed out: #cmd keeps showing it as typed.
65
+ const langAt = launch => valueAt(launch, 'lang');
66
+ // l/c/quiet/o/z/interactive/with-filename/version/format/color/verbose/dry-run/help: the page's own replacements
67
+ // for a search's output shape and tracing (REFUSED_LAUNCH's set, minus what parseOpts already refuses on its own:
68
+ // e/P/a/v/question/step-to/install-templates). r: unlike at launch, there is no target field here to keep a bare -r
69
+ // (or a --serve started with no target, which then defaults to ".") in check. sys1-url/sys1-api-key: their own
70
+ // fields.
71
+ const DENY = new Set(['l', 'c', 'quiet', 'o', 'z', 'interactive', 'with-filename', 'version', 'format', 'color',
72
+ 'verbose', 'dry-run', 'help', 'r', 'gitlog', 'sys1-url', 'sys1-api-key']);
73
+ const optsError = opts => {
74
+ let tokens;
75
+ try { ({ tokens } = parseOpts(opts.map(a => fill(a, 'x')))); }
76
+ catch (e) { return e.message; }
77
+ // #234 (#233 review on #198): --unit=zero is -z's output shape, refused at launch (unitAt above); opts resolves
78
+ // "unit" to a plain string option DENY never sees, so it slipped through here until now.
79
+ if (unitAt(opts) === 'zero') return "--unit=zero is not allowed (it is -z's output shape, already refused there)";
80
+ const bad = tokens.find(k => DENY.has(k.name));
81
+ if (!bad) return null;
82
+ const tok = opts[bad.index]; // the original, unfilled element: a cluster like "-nr" stays "-nr", not just "-r"
83
+ const why = bad.name === 'r' ? "it would widen the search past the launch's own targets"
84
+ : bad.name === 'gitlog' ? "it would switch the search from the launch's targets to git log; Details has -g"
85
+ : bad.name === 'sys1-url' || bad.name === 'sys1-api-key' ? 'use the url / key field instead'
86
+ : 'meanings, targets and output shape go on the command line';
87
+ return `${tok} is not allowed (${why})`;
88
+ };
89
+ const isHttpUrl = s => { try { return /^https?:$/.test(new URL(s).protocol); } catch { return false; } };
90
+ // A POST's body: the fields to change. A string or a non-empty array sets one, null, '' or [] removes it, and a field
91
+ // left out stays as it is (so the page sends a key only when one is typed). opts/url equal to their own masked form
92
+ // (settingsView's GET, resent unchanged) keep the file's real value, so a round trip never overwrites a key or
93
+ // userinfo with the mask itself.
94
+ const saveSettings = patch => {
95
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('not a JSON object');
96
+ const next = readSettings() ?? {};
97
+ for (const [k, v] of Object.entries(patch)) {
98
+ if (!Object.hasOwn(FIELDS, k)) throw new Error(`unknown field ${k}`);
99
+ let value = v;
100
+ if (k === 'opts' && Array.isArray(v)) {
101
+ if (!v.every(a => typeof a === 'string')) throw new Error('opts must be an array of strings');
102
+ // An unchanged round trip skips optsError on purpose: a file written before this refusal existed, or edited
103
+ // by hand, could already hold something optsError would now reject (a positional, say); resending it as-is
104
+ // must not break (sys1grep.mjs still refuses it on the next search), only a real edit is validated here.
105
+ if (Array.isArray(next.opts) && JSON.stringify(v) === JSON.stringify(maskOpts(next.opts))) value = next.opts;
106
+ else { const e = optsError(v); if (e) throw new Error(`opts: ${e}`); }
107
+ }
108
+ if (k === 'url' && typeof v === 'string' && v) {
109
+ if (typeof next.url === 'string' && v === maskUrl(next.url)) value = next.url;
110
+ else {
111
+ // A url that still carries a masked username or password, whole or in part (the host, path or the other
112
+ // half of the userinfo was edited around it), would otherwise save "***" itself as a real credential.
113
+ // URL's username/password getters return the percent-encoded form (https://%2A%2A%2A@... is "***"
114
+ // percent-encoded), so this decodes before comparing, or the same mask spelled that way would slip through.
115
+ let parsed;
116
+ try { parsed = new URL(v); } catch {}
117
+ const decoded = s => { try { return decodeURIComponent(s); } catch { return s; } };
118
+ if (parsed && (decoded(parsed.username) === '***' || decoded(parsed.password) === '***')) throw new Error('url: re-enter the userinfo (the part before @) in full, or remove it, before saving');
119
+ if (!isHttpUrl(v)) throw new Error('url must be http(s)');
120
+ }
121
+ }
122
+ if (k === 'summarizer' && typeof v === 'string' && /^https?:\/\//.test(v) && !isHttpUrl(v)) throw new Error('summarizer must be http(s) when it is a URL');
123
+ if (k === 'lang' && typeof v === 'string' && v && !['en', 'ja'].includes(v)) throw new Error("lang must be en or ja");
124
+ if (value === null || value === '' || (Array.isArray(value) && !value.length)) delete next[k];
125
+ else next[k] = value;
126
+ }
127
+ writeSettings(next);
128
+ };
129
+ // the summary's bound in sys1grep (SUMMARY_MAX): results on screen beyond it are refused
130
+ const POSTED_MAX = 200 * 1024;
131
+ const body = (req, max = 1 << 16) => new Promise((resolve, reject) => {
132
+ let b = '', n = 0;
133
+ req.setEncoding('utf8');
134
+ // Past max the rest is read and dropped, not left unread: a client that sends the whole body before it reads gets the
135
+ // answer (413) once it is done, where an unread or closed request either stalls it or resets the connection under
136
+ // the answer (Linux drops data not yet read when a reset arrives).
137
+ const take = c => { b += c; n += Buffer.byteLength(c); if (n > max) { req.tooLarge = true; b = ''; req.off('data', take).on('data', () => {}); reject(new Error('too large')); } };
138
+ req.on('data', take);
139
+ req.on('end', () => resolve(b));
140
+ req.on('error', reject);
141
+ });
142
+
143
+ // The page's controls. toArgv() (below) turns their values into command-line tokens, in the server to run a search and
144
+ // in the browser to print the command: one function, so the two cannot drift. A value equal to its launch value is left out.
145
+ // re: what the server accepts; bool: a checkbox, neg its negation; words: one token per word; step: only with a --step-to field.
146
+ // No flag: reverse adds no option of its own, it turns the field's --step-to into --step-reverse-to.
147
+ const CONTROLS = [
148
+ { k: 'rank', flag: '--rank', re: '^(jev|match)?$', nostep: true, def: '' },
149
+ { k: 'level', flag: '--level', re: '^(loose|normal|strict)$' },
150
+ { k: 't', flag: '-t', re: '^(\\d+(\\.\\d+)?)?$' },
151
+ { k: 'T', flag: '-T', re: '^(\\d+(\\.\\d+)?)?$' },
152
+ { k: 'C', flag: '-C', re: '^\\d*$', nostep: true },
153
+ // #234: -A/-B (lines of context, each side), like -C; multi-step prints paths, not lines (sys1grep.mjs's own
154
+ // conflict check), so these go inert there too.
155
+ { k: 'A', flag: '-A', re: '^\\d*$', nostep: true },
156
+ { k: 'B', flag: '-B', re: '^\\d*$', nostep: true },
157
+ { k: 'dedup', flag: '--dedup', re: '^(never|auto|always)$', nostep: true, def: 'never' },
158
+ // No def: a multi-step walk's own default unit is function (sys1grep.mjs), not line, so the control goes
159
+ // inert (like C) rather than forcing a value back. Whatever the launch already set keeps applying unchanged.
160
+ { k: 'unit', flag: '--unit', re: '^(line|sentence-by-jev|sentence-by-rule|function)$', nostep: true },
161
+ // #234 (#211 item 10, owner): -o, shown only with a sentence unit (sentenceOnly, below); def: false, so it is
162
+ // never sent (not left inert on a stale checked box) once the unit picked away from sentence hides it.
163
+ { k: 'o', flag: '-o', neg: '--no-o', bool: true, nostep: true, sentenceOnly: true, def: false },
164
+ { k: 'auto-scope', flag: '--auto-scope', neg: '--no-auto-scope', bool: true },
165
+ { k: 'n', flag: '-n', neg: '--no-n', bool: true },
166
+ { k: 'p', flag: '-p', neg: '--no-p', bool: true },
167
+ { k: 'g', flag: '-g', neg: '--no-gitlog', bool: true, nostep: true, def: false },
168
+ // #234: -j (concurrent requests) and --max-cost (the cost guard) apply to a walk's requests too, unlike C/A/B/o/dedup.
169
+ { k: 'j', flag: '-j', re: '^\\d*$' },
170
+ { k: 'max-cost', flag: '--max-cost', re: '^(\\d+(\\.\\d+)?)?$' },
171
+ { k: 'include', flag: '--include', words: true },
172
+ { k: 'exclude', flag: '--exclude', words: true },
173
+ { k: 'changed-within', flag: '--changed-within' },
174
+ { k: 'hops', flag: '--hops', re: '^[\\d.]*$', step: true, def: '0..' },
175
+ { k: 'reverse', bool: true, step: true, def: false },
176
+ ];
177
+ const FIELD = /^[eavQPS]:/; // an expression field: e:MEANING (-e), a, v, Q (-Q), P:/RE/ (-P), S: (--step-to)
178
+ // The example buttons: x as the page sends it, then the controls it sets (by k). Each fits a user's own project and also
179
+ // has something to find when --serve is started on this repository (#224): the meaning ones in the corpora under tests/
180
+ // or sys1grep's own code, the multi-step ones in its JavaScript (ENTRY and RETHROW take Python too; SRC keeps the walk
181
+ // in source files).
182
+ // hj / xj: the label and fields in Japanese (#215), for the page's EN / 日本語 switch; a regex example keeps its x.
183
+ // Pressing one puts the page in that state: the rest go back to their launch values; an S: in x turns multi-step on.
184
+ const ENTRY = '/^export (async )?function |^export const [\\w$]+ = (async )?\\(|^(async )?function main\\b|^ *def main\\b/'; // a module's entry points
185
+ // a caught error thrown on: any throw would put the entry points (which throw) at both ends, with no walk between; m: a
186
+ // walk matches a whole function at once, and ^ $ must mean a line in it (#225 review)
187
+ const RETHROW = '/throw (e|err|error)\\b|^ *raise$/m';
188
+ const SRC = '*.mjs *.cjs *.js *.jsx *.ts *.tsx *.py'; // not *.mts: tests/smoke.mts would put its search() in the walk
189
+ export const EXAMPLES = [
190
+ { h: 'meaning, not words', hj: '言葉ではなく意味で', x: ['e:customer is angry or frustrated'], xj: ['e:顧客が怒っている、または不満を持っている'], n: true },
191
+ { h: 'a question (-Q)', hj: '質問する (-Q)', x: ['Q:whether the server is down'], xj: ['Q:サーバーが落ちているかどうか'], n: true },
192
+ { h: 'where an API key is read', hj: 'API キーを読んでいる箇所', x: ['e:an API key or token is read from the environment or a settings file'],
193
+ xj: ['e:API キーやトークンを環境変数や設定ファイルから読んでいる'], n: true },
194
+ { h: 'a request is retried', hj: 'リクエストの再試行', x: ['e:a failed request is retried'], xj: ['e:失敗したリクエストを再試行している'], n: true },
195
+ { h: 'either meaning, with scores (-p)', hj: 'どちらかの意味(スコア付き -p)', x: ['e:customer is asking for a refund', 'e:delivery address change request'],
196
+ xj: ['e:顧客が返金を求めている', 'e:配送先の変更依頼'], n: true, p: true },
197
+ { h: 'exclude with -v', hj: '-v で除外', x: ['e:network or remote connection failure', 'v:a retry is happening or was attempted'],
198
+ xj: ['e:ネットワークまたはリモート接続の失敗', 'v:リトライが行われた、または試みられた'], n: true },
199
+ { h: 'regex and meaning together', hj: '正規表現と意味を併用', x: ['P:/catch/', 'a:the error is ignored'], xj: ['P:/catch/', 'a:エラーが無視されている'], n: true, p: true },
200
+ { h: 'stricter (--level strict)', hj: 'より厳密に (--level strict)', x: ['e:a security risk or dangerous destructive operation'],
201
+ xj: ['e:セキュリティ上のリスク、または危険な破壊的操作'], level: 'strict', n: true },
202
+ { h: 'one line per template (--dedup)', hj: 'テンプレートごとに1行 (--dedup)', x: ['e:a request failed'], xj: ['e:リクエストが失敗した'], dedup: 'always', n: true },
203
+ { h: 'one sentence at a time', hj: '1文ずつ判定', x: ['e:the author admits they made a mistake'], xj: ['e:筆者が自分の間違いを認めている'], unit: 'sentence-by-jev', n: true },
204
+ { h: 'calls that throw or raise', hj: 'throw / raise に至る呼び出し', x: ['P:' + ENTRY, 'S:', 'P:/throw |raise /'], include: SRC },
205
+ { h: '1 to 2 calls away', hj: '1〜2 段先の呼び出し', x: ['P:' + ENTRY, 'S:', 'P:/throw |raise /'], hops: '1..2', include: SRC },
206
+ { h: 'what reaches it (--step-reverse-to)', hj: 'どこから呼ばれるか (--step-reverse-to)', x: ['P:' + RETHROW, 'S:', 'P:' + ENTRY], reverse: true, include: SRC },
207
+ { h: 'throw or exit (two ends)', hj: 'throw または exit(終点が2つ)', x: ['P:' + ENTRY, 'S:', 'P:/throw |raise /', 'P:/process\\.exit\\(|sys\\.exit\\(/'], include: SRC },
208
+ ];
209
+
210
+ // The whole page's strings (#270), one table like ASK already was for its four: en, with a ja translation; a key
211
+ // with no ja falls back to en (t(), below). Not here: what sys1grep itself prints, the #cmd command line, option
212
+ // names (-n, --step-to, ...) and the literal values a <select> sends (jev/match, loose/normal/strict, ...).
213
+ export const STRINGS = {
214
+ settings: ['Settings', '設定'], close: ['Close', '閉じる'],
215
+ target: ['Target', 'ターゲット'],
216
+ targetHint: ["what every search reads, set when --serve starts; the page cannot change it", '起動時に --serve が決めた検索対象。ページからは変更できない'],
217
+ cond: ['Search conditions', '検索条件'],
218
+ startLegend: ['start', '開始'], startLegendHint: ['what to search for; with multi-step on, the start of the walk', '何を探すか。多段階がオンのときは、たどる経路の開始点'],
219
+ addStart: ['+ start', '+ 開始'], addStartTitle: ['add a start', '開始条件を追加'],
220
+ multiStep: ['multi-step', '多段階'],
221
+ multiStepHint: [
222
+ "Finds the start functions, then walks calls from them to the functions that match the end.\nA call is found by name: `name(` in a function's body (comments and strings left out) links to every function called `name`. Same-named functions all link; calls not written as `name(` (a function passed as a value, a call through a variable) are missed.\nDefault: from a function to the functions it calls. reverse (--step-reverse-to): to the functions that call it.\n--hops limits how many calls away an end may be. For exact relations, start --serve with --edges=FILE.",
223
+ '開始の関数を見つけ、そこから呼び出しをたどって終了に一致する関数を探す。\n呼び出しは名前で見つける: 関数の本体(コメントと文字列を除く)にある `name(` が、`name` という名前のすべての関数にリンクする。同名の関数はすべてリンクし、`name(` の形でない呼び出し(値として渡された関数、変数経由の呼び出し)は見逃す。\n既定: ある関数からそれが呼び出す関数へ。reverse (--step-reverse-to): その関数を呼んでいる関数へ。\n--hops で終了点までの段数を制限できる。正確な関係を使うには --edges=FILE を付けて --serve を起動する。',
224
+ ],
225
+ endLegend: ['end (--step-to)', '終了 (--step-to)'], addEnd: ['+ end', '+ 終了'], addEndTitle: ['add an end', '終了条件を追加'],
226
+ edgesLabel: ['edges', 'エッジ'],
227
+ edgesHint: ["--hops limits how many hops away an end may be; reverse (--step-reverse-to) searches backwards along the graph", '--hops で終了点までの段数を制限できる。reverse (--step-reverse-to) はグラフを逆方向にたどる'],
228
+ hopsTitle: ['how many hops the end may be from the start, e.g. 1..2', '開始点から終了点までの段数、例: 1..2'],
229
+ reverseLabel: ['reverse', '逆方向'],
230
+ go: ['Search', '検索'],
231
+ est: ['Estimate cost', '費用を見積もる'],
232
+ estTitle: ['run with --dry-run: shows the request size and estimated cost, no results', '--dry-run で実行し、リクエストサイズと概算費用を表示する(結果は出さない)'],
233
+ stop: ['Stop', '中止'], stopTitle: ["stop the search that is running (its process is killed)", '実行中の検索を中止する(プロセスを終了する)'],
234
+ opts: ['Options', 'オプション'],
235
+ rank: ['rank', 'ランク'], rankOff: ['off', 'オフ'], rankHint: ['off keeps file order', 'オフならファイル内の順序のまま'],
236
+ summarize: ['summarize', '要約'], summarizeHint: ['answers in a sentence or two after the search', '検索後に1〜2文で答える'],
237
+ summarizePrompt: ['summary instruction', '要約の指示'],
238
+ examples: ['examples', '例'],
239
+ command: ['Command', 'コマンド'],
240
+ cmdTitle: ["the command line for the fields and controls as they are now; pasted in a terminal it runs the same search. The API key is never in it", '今の欄と設定からのコマンドライン。端末に貼り付けると同じ検索が走る。API キーは含まれない'],
241
+ copy: ['Copy', 'コピー'], copyTitle: ['copy the command line to the clipboard', 'コマンドラインをクリップボードにコピーする'], copied: ['Copied', 'コピーしました'],
242
+ cmdDemoNote: [
243
+ 'here it searches the bundled <code>examples/</code>; pasted elsewhere it reads stdin instead &mdash; add a file or -r DIR',
244
+ 'ここでは同梱の <code>examples/</code> を検索するが、よそへ貼り付けると代わりに標準入力を読む &mdash; ファイルか -r DIR を加えること',
245
+ ],
246
+ details: ['Details', '詳細'], detailsTitle: ["the other options; a dot means one differs from its launch value", 'その他のオプション。点が付いているものは起動時の値と異なる'],
247
+ level: ['level', 'レベル'], levelHint: ['how strict a match must be (--level): loose takes more lines, strict fewer', '一致の厳しさ(--level)。loose は多く拾い、strict は少なく絞る'],
248
+ tHint: ['-t: a line matches when its probability is at least this (0 to 1)', '-t: 行の確率がこの値以上ならマッチ(0〜1)'],
249
+ THint: ['-T: a negated meaning holds when the probability is below this (0 to 1)', '-T: 否定した意味は確率がこの値未満のとき成立(0〜1)'],
250
+ cLabel: ['context lines (-C)', '前後の行数 (-C)'], cHint: ['-C: lines of context before and after each match', '-C: 各マッチの前後の行数'],
251
+ aHint: ['-A: lines of context after each match (trailing only)', '-A: 各マッチの後ろだけの行数'],
252
+ bHint: ['-B: lines of context before each match (leading only)', '-B: 各マッチの前だけの行数'],
253
+ autoscope: ['auto-scope', '自動スコープ'],
254
+ autoscopeHint: ["--auto-scope: narrow the files by what Jev says the meaning restricts to (a language, a kind of file); off is --no-auto-scope", '--auto-scope: 意味が絞り込む対象(言語やファイルの種類)を Jev に判定させてファイルを絞る。オフは --no-auto-scope'],
255
+ nLabel: ['-n line numbers', '-n 行番号'], nHint: ['-n: show line numbers', '-n: 行番号を表示する'],
256
+ pLabel: ['-p scores', '-p スコア'], pHint: ["-p: show the probability of each meaning on the matching lines", '-p: マッチした行に各意味の確率を表示する'],
257
+ dedup: ['dedup', '重複排除'],
258
+ dedupHint: ["--dedup: judge one line per template (lines that differ only in ids, numbers, dates) and reuse the answer; auto does it when it pays", '--dedup: id や数値、日付だけが違う行はテンプレートごとに1行だけ判定し、その答えを使い回す。auto は割に合うときだけ行う'],
259
+ unit: ['unit', '単位'],
260
+ unitHint: [
261
+ "--unit: what one question judges: a line, a sentence (found by Jev or by a rule), or a function (git grep -W's boundaries). A multi-step walk is by function regardless of this. zero (-z, same as --unit=zero) is refused here, like -z itself",
262
+ '--unit: 1つの質問が何を判定するか: 行、文(Jev またはルールで見つける)、または関数(git grep -W の範囲)。多段階の探索はこれによらず関数単位。zero(-z と同じ --unit=zero)は -z 自身と同様にここでは拒否される',
263
+ ],
264
+ oLabel: ['-o matching sentences only', '-o マッチした文のみ'], oHint: ['-o: with a sentence unit, print only the matching sentences, not the whole line', '-o: 文単位のとき、行全体ではなくマッチした文だけを表示する'],
265
+ jHint: ['-j: concurrent requests to the API', '-j: API への同時リクエスト数'],
266
+ maxcostHint: ['--max-cost: ask before sending when the estimated price is over this many USD', '--max-cost: 見積もり額がこの USD を超えるとき送信前に確認する'],
267
+ includeHint: ["--include: only file names matching these globs, space separated. Applies to -r and git sys1grep; it narrows the launch targets, never widens them", '--include: これらの glob(空白区切り)に一致するファイル名だけを対象にする。-r と git sys1grep に適用され、起動時の対象を狭めるだけで広げない'],
268
+ excludeHint: ["--exclude: not file names matching these globs, space separated. Applies to -r and git sys1grep", '--exclude: これらの glob(空白区切り)に一致するファイル名は対象から外す。-r と git sys1grep に適用される'],
269
+ changedHint: ['--changed-within: only files modified within 30m, 2h, 7d, 2w, or since a date. Applies to -r and git sys1grep', '--changed-within: 30m・2h・7d・2w以内、または指定日以降に更新されたファイルだけを対象にする。-r と git sys1grep に適用される'],
270
+ gLabel: ['-g git log', '-g git log'], gHint: ["-g: search git log's commits, one record each", '-g: git log のコミットを1件ずつ検索する'],
271
+ enterMeaning: ['Enter a meaning.', '意味を入力してください。'], enterEnd: ['Enter an end (--step-to).', '終了条件 (--step-to) を入力してください。'],
272
+ searching: ['searching...', '検索中...'], summarizing: ['summarizing...', '要約中...'], stopped: ['stopped', '中止しました'],
273
+ noMatches: ['no matches', '一致なし'],
274
+ searchAnyway: ['Search anyway', '検索を続ける'],
275
+ save: ['Save to ~/.config/sys1grep/settings.json', '~/.config/sys1grep/settings.json に保存'],
276
+ saved: ['saved; the next search uses it', '保存しました。次の検索から反映されます'],
277
+ notSaved: ['not saved: ', '保存できませんでした: '],
278
+ saidCmd: ['the command line wins over this', 'コマンドラインが優先される'],
279
+ saidSettings: ['in effect', '有効'], saidDotEnv: ['in effect: ~/.config/sys1grep/.env', '有効: ~/.config/sys1grep/.env'], saidDefault: ['not set: the default', '未設定(既定値)'],
280
+ saidEnvSuffix: [' in the environment wins over this', '(環境変数)が優先される'],
281
+ fieldModel: ['model', 'モデル'], fieldKey: ['key', 'キー'], fieldOpts: ['opts, one argument per line', 'opts(1行に1引数)'],
282
+ fieldSummarizer: ['summarizer', '要約ツール'], fieldSummarizerModel: ['summarizerModel', '要約のモデル'], fieldSummarizerKey: ['summarizerKey', '要約の API キー'],
283
+ fieldLang: ['language', '言語'], remove: ['remove', '削除'],
284
+ termStart: ['start', '開始'], termEnd: ['end', '終了'],
285
+ termE: ['-e meaning', '-e 意味'], termP: ['-P regex', '-P 正規表現'], termA: ['-a and', '-a かつ'], termV: ['-v and not', '-v かつ否定'], termQ: ['-Q question', '-Q 質問'],
286
+ tipQph: ['a question', '質問'], tipQhint: ['the lines that answer this question, not the lines asking it, e.g. why did it fail. A question is never a regex: use -P for /regex/', 'この質問に答えている行(質問している行ではない)、例: なぜ失敗したか。質問は正規表現にはならない: /regex/ には -P を使う'],
287
+ tipEph: ['a meaning', '意味'], tipEhint: ['lines that mean this, in any language; several -e are OR. e.g. an API key is read. Always a meaning: use -P for /regex/', 'どの言語でもこの意味を持つ行。複数の -e は OR。例: API キーを読んでいる。常に意味として扱う: /regex/ には -P を使う'],
288
+ tipPhint: ['a regex term, matched locally, no request; several -P are OR, like -e. e.g. /catch/', 'ローカルで判定する正規表現(リクエストなし)。複数の -P は -e と同様 OR。例: /catch/'],
289
+ tipAph: ['and this meaning', 'かつこの意味'], tipAhint: ['AND onto the term before it: both must hold on the line', '直前の条件に AND で付く: 行は両方を満たす必要がある'],
290
+ tipVph: ['and not this meaning', 'かつこの意味ではない'], tipVhint: ['AND NOT onto the term before it: this must not hold (as the first field, a bare negation)', '直前の条件に AND NOT で付く: これが成り立ってはいけない(先頭の欄では単なる否定)'],
291
+ fakeJev: ["FAKE Jev: searches go to tests/fake-jev.mjs. Its probabilities are dummies, not real answers.", '偽の Jev: 検索は tests/fake-jev.mjs へ行く。確率はダミーで本物の答えではない'],
292
+ demoBody: [
293
+ "<strong>This is a demo of sys1grep that only shows answers cached in advance.</strong> A search that calls Jev (the model that judges meanings) with anything other than the searches under \"examples\" below shows no results. A search made only of regular expressions (<code>-P</code>) does not call Jev, so it works even when you write it yourself. Everything runs in this browser, on the bundled sample files (<code>examples/</code>). The summary of the results (summarize) uses the summarizer built into the browser (Chrome 138 or later, Gemini Nano), so it is not sent anywhere either; a browser without one cannot summarize.<br>To search for real, run <code>npx @uehaj/sys1grep --serve</code> on your own machine (needs a Jev API key).",
294
+ '<strong>これは、事前にキャッシュした回答を表示するだけの sys1grep のデモ版です。</strong> 以下の「例」にある検索以外の内容で Jev(意味を判定するモデル)を呼び出す検索は、結果が表示されません。正規表現(<code>-P</code>)だけの検索は Jev を呼ばないので、自分で書いたものでも動きます。検索はすべてこのブラウザの中で、付属のサンプルファイル(<code>examples/</code>)に対して行います。検索結果の要約(summarize)は、ブラウザに内蔵された要約機能(Chrome 138 以降の Gemini Nano)を使うので、これも外へは送りません。この機能がないブラウザでは要約できません。<br>実際に検索するには、ローカルマシンで <code>npx @uehaj/sys1grep --serve</code> を実行してください(Jev の API キーが必要です)。',
295
+ ],
296
+ demoMissing: ['The recording is not published yet, so this demo runs no search for now.', '録音がまだ公開されていないため、このデモは今のところ検索を実行しません。'],
297
+ };
298
+ const t = (lang, key) => STRINGS[key]?.[lang === 'ja' ? 1 : 0] ?? STRINGS[key]?.[0] ?? key;
299
+
300
+ // p: the page's values (x: the fields, then one value per control and summarize / summarize-prompt); init: the launch's.
301
+ export const toArgv = (p, init) => {
302
+ const v = k => p[k] ?? init[k], out = [];
303
+ const step = (p.x ?? init.x).some(f => f[0] === 'S');
304
+ for (const f of v('x')) {
305
+ if (f[0] === 'S') out.push(v('reverse') ? '--step-reverse-to' : '--step-to');
306
+ else if (f.slice(2)) out.push(`-${f[0]}`, f.slice(2));
307
+ }
308
+ const unit = v('unit');
309
+ for (const c of CONTROLS) {
310
+ if (!c.flag) continue;
311
+ // what does not apply goes back to its default, over a launch value; sentenceOnly (-o): only with a sentence unit
312
+ const off = (c.step && !step) || (c.nostep && step) || (c.sentenceOnly && !/^sentence-by-/.test(unit));
313
+ if (off && c.def === undefined) continue;
314
+ const now = off ? c.def : v(c.k), was = init[c.k];
315
+ if (now === was) continue;
316
+ if (c.bool) out.push(now ? c.flag : c.neg);
317
+ else if (c.k === 'rank') out.push(now ? `--rank=${now}` : '--no-rank');
318
+ else if (now === '') continue;
319
+ else if (c.words) for (const w of now.split(/\s+/).filter(Boolean)) out.push(`${c.flag}=${w}`);
320
+ else out.push(c.flag.startsWith('--') ? `${c.flag}=${now}` : c.flag, ...(c.flag.startsWith('--') ? [] : [now]));
321
+ }
322
+ const sum = v('summarize') && !step;
323
+ if (sum !== init.summarize) out.push(sum ? '--summarize' : '--no-summarize');
324
+ if (sum && v('summarize-prompt')) out.push(`--summarize-prompt=${v('summarize-prompt')}`);
325
+ return out;
326
+ };
327
+ // A search's arguments after the launch's, and the page's answer from how it exited: the server's search() and the
328
+ // demo page's in-browser runner (#200) both use these, stringified into the client like toArgv, so the two differ
329
+ // only in where the arguments run. A results search never carries --summarize: the summary is a separate step,
330
+ // over what's on screen (#231, /summarize below).
331
+ export const tplOf = launch => (launch.some(a => a.startsWith('--template')) ? [] : ['--template=search']);
332
+ export const searchArgs = (p, kind, init, tpl) => {
333
+ const args = toArgv({ ...p, summarize: false }, init);
334
+ if (kind === 'dry') args.push('--dry-run', '--no-summarize');
335
+ else if (!p.x.some(f => f[0] === 'S') && (p.rank ?? init.rank)) args.push('--format=html', ...tpl);
336
+ return [`--color=${kind === 'results' ? 'always' : 'never'}`, ...args];
337
+ };
338
+ export const answerOf = (p, kind, init, { code, out, err }) => {
339
+ if (code > 1) return { error: err.trim() || `exit ${code}` };
340
+ if (code === 1 && !out) return { none: true, note: err.trim() };
341
+ return kind === 'results' && !p.x.some(f => f[0] === 'S') && (p.rank ?? init.rank) ? { html: out.replace('</head>', '<style>.bar{display:none}</style></head>') } : { text: out };
342
+ };
343
+ // #234 (Q1, measured 2026-10-12): a search over --max-cost is refused at once, naming -y and --max-cost, neither of
344
+ // which the page can set. The refusal already carries the same estimate --dry-run would show (sys1grep.mjs prices it
345
+ // the same way for both), so this reads the price out of the error instead of firing a second --dry-run request:
346
+ // the bump a "Search anyway" button would use, or null when there is none. Exported (like toArgv/initOf) so it is
347
+ // both stringified into the client script and directly testable with no DOM.
348
+ // The price must come from the guard's own line (sys1grep.mjs's askToContinue), anchored on "about N input tokens,
349
+ // ~$X.XX": a bare /\$([\d.]+)/ over the whole stderr can match a $ earlier in it first -- auto-scope's own "scope:
350
+ // ..." line, or a skipped file's name, can carry one (#234 review).
351
+ // No bump in a multi-step walk (#234 review): the first refusal only prices the start side, a retry really sends
352
+ // it (sentRequests/sentBytes, sys1grep.mjs), and the end side's guard then compares the cumulative cost and can
353
+ // refuse again with no button of its own, after spending the start's cost for nothing. Details' --max-cost field
354
+ // (also #234, applies in a walk) is the way there instead.
355
+ export const costRetryOf = (p, error) => {
356
+ if ((p.x ?? []).some(f => f[0] === 'S')) return null;
357
+ const price = /needs a terminal to confirm on/.test(error ?? '') && /about [\d,]+ input tokens, ~\$([\d.]+)/.exec(error)?.[1];
358
+ return price ? (Math.round((Number(price) + 0.01) * 100) / 100).toFixed(2) : null;
359
+ };
360
+ // The launch's values, from the command line (what parseArgs cannot place is left at the default).
361
+ export const initOf = launch => {
362
+ const fill = { '--rank': '--rank=jev', '--summarize': '--summarize=x', '--dedup': '--dedup=always', '--no-rank': '--rank=\0', '--no-summarize': '--summarize=\0' };
363
+ const { values: o } = parseArgs({
364
+ args: launch.map(a => fill[a] ?? a), strict: false, allowPositionals: true, allowNegative: true,
365
+ options: { rank: { type: 'string' }, level: { type: 'string' }, t: { type: 'string' }, T: { type: 'string' }, C: { type: 'string' },
366
+ A: { type: 'string' }, B: { type: 'string' }, j: { type: 'string' }, 'max-cost': { type: 'string' },
367
+ dedup: { type: 'string' }, unit: { type: 'string' }, 'auto-scope': { type: 'boolean' }, n: { type: 'boolean' }, p: { type: 'boolean' },
368
+ g: { type: 'boolean' }, include: { type: 'string', multiple: true }, exclude: { type: 'string', multiple: true },
369
+ 'changed-within': { type: 'string' }, hops: { type: 'string' }, summarize: { type: 'string' },
370
+ 'summarize-prompt': { type: 'string' } },
371
+ });
372
+ const rank = o.rank === undefined || o.rank === '\0' ? '' : ['jev', 'match'].includes(o.rank) ? o.rank : 'jev'; // a bare --rank swallows the next word
373
+ return { x: [], rank, level: o.level ?? 'normal', t: o.t ?? '', T: o.T ?? '', C: o.C ?? '', A: o.A ?? '', B: o.B ?? '', j: o.j ?? '',
374
+ 'max-cost': o['max-cost'] ?? '',
375
+ // -o: never a launch option (REFUSED_LAUNCH), so the launch can never have set it; a constant, like C/A/B above an
376
+ // unset launch, needed only so toArgv's v('o') has an init to compare against.
377
+ o: false,
378
+ dedup: o.dedup ?? 'never', unit: o.unit ?? 'line',
379
+ 'auto-scope': o['auto-scope'] ?? true, n: !!o.n, p: !!o.p, g: !!o.g, include: (o.include ?? []).join(' '), exclude: (o.exclude ?? []).join(' '),
380
+ 'changed-within': o['changed-within'] ?? '', hops: o.hops ?? '0..', reverse: false, summarize: o.summarize !== undefined && o.summarize !== '\0' && o.summarize !== 'ask', // ask: the lines, unasked off a terminal
381
+ 'summarize-prompt': '' };
382
+ };
383
+
384
+ // What the page replaces or the meaning of which comes from the page: refused at launch.
385
+ // --NAME VALUE or --NAME=VALUE out of the arguments: [the rest, the value]
386
+ // --suggest=KIND:TEXT or --suggest KIND:TEXT, any number of times: pills put first among the examples (Q: a question, e: a meaning; a bare TEXT is a question)
387
+ const liftAll = (args, name) => {
388
+ const rest = [], values = [];
389
+ for (let i = 0; i < args.length; i++) {
390
+ if (args[i] === `--${name}`) { if (i + 1 >= args.length) throw new Error(`--serve: --${name} needs KIND:TEXT (Q:a question, e:a meaning)`); values.push(args[++i]); }
391
+ else if (args[i].startsWith(`--${name}=`)) values.push(args[i].slice(name.length + 3));
392
+ else rest.push(args[i]);
393
+ }
394
+ return [rest, values];
395
+ };
396
+ const suggestOf = list => list.map(v => (/^[eQ]:/.test(v) ? [v[0], v.slice(2)] : ['Q', v])).filter(([, t]) => t.trim()).map(([k, t]) => ({ h: t, x: [`${k}:${t}`], q: k }));
397
+ const lift = (args, name) => {
398
+ const i = args.findIndex(a => a === `--${name}` || a.startsWith(`--${name}=`));
399
+ if (i < 0) return [args, undefined];
400
+ const two = args[i] === `--${name}`;
401
+ return [args.filter((_, j) => j !== i && !(two && j === i + 1)), two ? args[i + 1] : args[i].slice(name.length + 3)];
402
+ };
403
+ const PARAMS = new Set(['x', 'summarize', 'summarize-prompt', ...CONTROLS.map(c => c.k)]);
404
+ const fromQuery = (sp, init) => { // null when a value is out of range
405
+ const p = { x: sp.getAll('x') };
406
+ if (!p.x.every(f => FIELD.test(f))) return null;
407
+ for (const k of PARAMS) if (k !== 'x' && sp.has(k)) {
408
+ const c = CONTROLS.find(c => c.k === k), s = sp.get(k);
409
+ if (c?.bool || k === 'summarize') { if (!/^[01]$/.test(s)) return null; p[k] = s === '1'; }
410
+ else { if (c?.re && !new RegExp(c.re).test(s)) return null; p[k] = s; }
411
+ }
412
+ return p.x.length ? p : { ...p, x: init.x };
413
+ };
414
+
415
+ // The bundled sample corpus (#237): shipped in the package next to serve.mjs, so `npx @uehaj/sys1grep --serve`
416
+ // with no target has something every example button can find, wherever it is run from.
417
+ const DEMO_DIR = fileURLToPath(new URL('examples/', import.meta.url));
418
+
419
+ // What every search reads (#216), shown on the page and never taken from it: the launch's own targets, or with none
420
+ // what sys1grep falls back to. none: that fallback, in words. cwd: what a relative target is relative to. -g (at
421
+ // launch or from Details) reads git log instead; the page shows that in place of these as -g goes on and off.
422
+ // demo/demoDir: no target, and -r / -g / git sys1grep all off -- the bundled corpus stands in for stdin (#237); the
423
+ // server adds it to the search it actually runs (serve(), below), never shown or alterable from the page.
424
+ // probe: #222's SYS1GREP_PROBE answer ({r,g,git}, or null if it failed), which resolves SYS1GREP_OPTS and
425
+ // settings.json opts the same way a real search would, not just this launch's own argv; o.r/o.gitlog (parsed from
426
+ // launch alone) are the fallback only when the probe itself could not be asked.
427
+ const targetsOf = (launch, after, probe) => {
428
+ const { values: o, positionals } = parseArgs({ args: launch.map(a => fill(a, 'x')), options: OPTIONS, strict: false, allowPositionals: true, allowNegative: true });
429
+ const list = [...positionals, ...after.slice(1)], cwd = process.cwd();
430
+ if (list.length) return { list, cwd };
431
+ const git = probe ? probe.git : globalThis.SYS1GREP_GIT, r = probe ? probe.r : !!o.r, g = probe ? probe.g : !!o.gitlog;
432
+ if (git) return { list, cwd, none: 'every file git tracks (git sys1grep)' };
433
+ if (r) return { list, cwd, none: '. (this folder, recursively: -r)' };
434
+ if (g) return { list, cwd, none: "git log's commits, not files (-g)" };
435
+ return { list, cwd, none: 'demo corpus (bundled): examples/', demo: true, demoDir: DEMO_DIR };
436
+ };
437
+
438
+ export function serve(argv) {
439
+ const isServe = a => /^--serve(=|$)/.test(a), port = argv.filter(isServe).at(-1).slice(8); // the last one's port: npm run serve adds a bare --serve
440
+ if (port && !(/^\d+$/.test(port) && Number(port) < 65536)) throw new Error(`--serve=${port}: not a port number`);
441
+ const rest = argv.filter(a => !isServe(a)), dd = rest.indexOf('--');
442
+ const before = dd < 0 ? rest : rest.slice(0, dd), after = dd < 0 ? [] : rest.slice(dd);
443
+ // the key stays in this process (never in the page or the command shown); the summary instruction is a control, not a launch option
444
+ const [noKey, key] = lift(before, 'sys1-api-key'), [noPrompt, prompt] = lift(noKey, 'summarize-prompt'), [launch, suggested] = liftAll(noPrompt, 'suggest');
445
+ if (launch.some(a => /^--(no-)?reverse(=|$)/.test(a))) throw new Error(REVERSE_REMOVED);
446
+ const { tokens } = parseArgs({ args: launch.map(a => fill(a, 'x')), options: OPTIONS, strict: false, allowPositionals: true, allowNegative: true, tokens: true });
447
+ const bad = tokens.find(k => k.kind === 'option' && REFUSED_LAUNCH.has(k.name) && !k.rawName.startsWith('--no-'));
448
+ if (bad) throw new Error(`--serve: ${launch[bad.index].split('=')[0]} is not for --serve (the page sets the meaning and shows the results as html)`);
449
+ if (unitAt(launch) === 'zero') throw new Error("--serve: --unit=zero is not for --serve (it is -z's output shape, already refused there)");
450
+ if (langAt(launch) !== undefined && !['en', 'ja'].includes(langAt(launch))) throw new Error(`--serve: --lang=${langAt(launch)} is not en or ja`);
451
+ const init = { ...initOf(launch), 'summarize-prompt': prompt ?? '' };
452
+ const secret = key === undefined ? [] : [`--sys1-api-key=${key}`];
453
+ const cli = { url: lift(launch, 'sys1-url')[1], model: lift(launch, 'sys1-model')[1], key, lang: langAt(launch) };
454
+ const token = randomBytes(16).toString('hex');
455
+ const script = process.argv[1];
456
+ // #222/#237: whether -r / -g / git sys1grep are in effect, asked of sys1grep itself (SYS1GREP_PROBE) so SYS1GREP_OPTS,
457
+ // settings.json opts and a later --no-r all count, as a real search would resolve them, not just this launch's own
458
+ // argv. Used both to refuse a bare directory target without -r (below) and to decide the demo corpus (targetsOf).
459
+ const probe = (() => {
460
+ const { stdout, stderr } = spawnSync(process.execPath, [script, ...launch], { input: '', encoding: 'utf8', timeout: 10_000, env: { ...process.env, SYS1GREP_PROBE: 'recursive' } });
461
+ try { return JSON.parse(stdout); } catch { if (/is not en or ja/.test(stderr ?? '')) throw new Error(stderr.split('\n')[0].replace(/^sys1grep: /, '')); return null; } // SYS1GREP_LANG / settings lang=fr: refuse at launch, not on every search
462
+ })();
463
+ // The page says so when searches go to the fake Jev (tests/fake-jev.mjs): a GET to it answers its own counters,
464
+ // which no real endpoint does. Only a loopback URL is asked, so a remote Jev never gets a request from this, and a
465
+ // GET is never judged or billed. A redirect is never followed: it could lead off loopback. Asked at launch, and again
466
+ // after every settings save (the url may have changed), of what sys1grep itself then resolves.
467
+ const fakeAt = async url => {
468
+ try {
469
+ if (!/^(localhost|127\.0\.0\.1|\[::1\])$/.test(new URL(url).hostname)) return false;
470
+ const j = await (await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(1000) })).json();
471
+ return ['count', 'asked', 'qmax'].every(k => k in j);
472
+ } catch { return false; }
473
+ };
474
+ // reprobe(): the same probe as above, rerun after a settings save (the url, and now the lang default, may have
475
+ // changed); fake and lang below both lazily follow it.
476
+ const reprobe = () => new Promise(resolve => {
477
+ execFile(process.execPath, [script, ...launch], { timeout: 10_000, env: { ...process.env, SYS1GREP_PROBE: 'recursive' } },
478
+ (e, out) => { try { resolve(JSON.parse(out)); } catch { resolve(null); } }).stdin.end();
479
+ });
480
+ let fake = fakeAt(probe?.url);
481
+ // #270: the page's default language, from the launch, environment or settings.json (never this process' locale);
482
+ // null when none of them set one, so the page falls back to the browser's own navigator.language.
483
+ let pageLang = probe?.lang ?? null;
484
+ const targets = targetsOf(launch, after, probe);
485
+ // #222: a folder searched without -r fails every search ("Is a directory (use -r)"), so refuse it before listening.
486
+ const dir = targets.list.find(t => statSync(t, { throwIfNoEntry: false })?.isDirectory());
487
+ if (dir) {
488
+ if (!probe) console.error('sys1grep: --serve: could not check whether -r is in effect');
489
+ const sh = a => (/^[\w@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`);
490
+ if (probe && !probe.r && !probe.g && !probe.git) throw new Error(`--serve: ${dir} is a directory: add -r to search it (${process.env.npm_lifecycle_event === 'serve' ? 'npm run serve --' : 'sys1grep --serve'} -r ${dir.startsWith('-') ? '-- ' : ''}${sh(dir)})`);
491
+ }
492
+ const tpl = tplOf(launch);
493
+ const { SYS1GREP_SUMMARIZE, ...searchEnv } = process.env; // set by the user, it would turn every search into a summary
494
+ // Demo mode (#237) adds -r DEMO_DIR to what a search actually runs, never to launch/LAUNCH (the copy-command box and
495
+ // the client script keep showing the command as typed, not sys1grep's own bundled path -- the page's "reads the
496
+ // bundled examples/" note next to Copy says so). Only when that search is not itself reading git log: gitlog is
497
+ // this search's own effective -g (the launch's, or a per-search Details toggle, whichever toArgv would send) --
498
+ // -g never needs a target, and sys1grep refuses -g together with -r outright.
499
+ const run = (args, signal, input = '', env = searchEnv, gitlog = false) => new Promise(resolve => {
500
+ const execLaunch = targets.demo && !gitlog ? [...launch, '-r', targets.demoDir] : launch;
501
+ const child = execFile(process.execPath, [script, ...execLaunch, ...secret, ...args, ...after], { signal, env, maxBuffer: 1 << 28 },
502
+ (e, out, err) => resolve({ code: e ? (typeof e.code === 'number' ? e.code : 2) : 0, out, err }));
503
+ child.stdin.on('error', () => {});
504
+ child.stdin.end(input); // no targets means stdin: an empty one, not a hang
505
+ });
506
+
507
+ const search = async (p, kind, signal) => {
508
+ const step = p.x.some(f => f[0] === 'S');
509
+ return answerOf(p, kind, init, await run(searchArgs(p, kind, init, tpl), signal, '', searchEnv, step ? init.g : p.g ?? init.g));
510
+ };
511
+
512
+ const via = process.env.PORTLESS_URL ? new URL(process.env.PORTLESS_URL) : null;
513
+ const server = createServer(async (req, res) => {
514
+ const host = req.headers.host;
515
+ const send = (code, type, body) => {
516
+ res.writeHead(code, { 'content-type': `${type}; charset=utf-8`, 'cache-control': 'no-store', 'x-frame-options': 'DENY', ...(type === 'application/json' ? { 'x-content-type-options': 'nosniff' } : {}) });
517
+ res.end(body);
518
+ };
519
+ // req.url can be something new URL() rejects outright (a bare "//..." parses as scheme-relative and throws
520
+ // ERR_INVALID_URL): answered before any check below, not left to crash the process (an uncaughtException, #166).
521
+ let url;
522
+ try { url = new URL(req.url, 'http://x'); } catch { return send(400, 'text/plain', 'bad request'); }
523
+ // a page on another site (or a rebound name) must not be able to start a search; behind portless (npm run
524
+ // serve:portless) the Host is the name it gave, PORTLESS_URL's
525
+ if (host !== `127.0.0.1:${server.address().port}` && host !== `localhost:${server.address().port}` && host !== via?.host) return send(403, 'text/plain', 'forbidden');
526
+ const origin = host === via?.host ? via.origin : `http://${host}`;
527
+ // Every route, the page itself included, needs this launch's token: in the query (?k=, what the startup line and
528
+ // any browser open carry) or in a header (what the page's own fetch() calls send instead). Without it this
529
+ // server answers nothing, not even the page, so another local account or process cannot read the page (and the
530
+ // token it carries, and anything a search of these targets would show) merely by knowing the port.
531
+ if (url.searchParams.get('k') !== token && req.headers['x-sys1grep-token'] !== token) return send(404, 'text/plain', 'not found');
532
+ if (url.pathname === '/') return send(200, 'text/html', page({ init, launch, after, token, targets, suggest: suggestOf(suggested), fake: await fake, lang: pageLang, by: settingsView(cli, launch).by }));
533
+ // The settings panel. It changes a file, so beyond the token above: for a write, POST with a JSON body and this
534
+ // request's own Host as Origin (a page on another site cannot forge that, and GET never sends Origin at all, so
535
+ // only POST is checked). A key goes in, never out.
536
+ if (url.pathname === '/settings') {
537
+ const answer = (code, x) => send(code, 'application/json', JSON.stringify(x));
538
+ try {
539
+ if (req.method === 'POST') {
540
+ if (req.headers.origin !== origin) return send(403, 'text/plain', 'forbidden');
541
+ if (!/^application\/json(;|$)/.test(req.headers['content-type'] ?? '')) return send(415, 'text/plain', 'JSON only');
542
+ let patch;
543
+ try { patch = JSON.parse(await body(req)); } catch { return req.tooLarge ? answer(413, { error: 'too large: settings are at most 64 KB' }) : answer(400, { error: 'not valid JSON' }); }
544
+ try { saveSettings(patch); } catch (e) { return answer(400, { error: e.message }); }
545
+ const rp = reprobe();
546
+ fake = rp.then(j => fakeAt(j?.url));
547
+ pageLang = (await rp)?.lang ?? null;
548
+ } else if (req.method !== 'GET') return send(405, 'text/plain', 'GET or POST');
549
+ return answer(200, { ...settingsView(cli, launch), fake: await fake, pageLang });
550
+ } catch (e) { return answer(500, { error: `~/.config/sys1grep/settings.json: ${e.message}` }); }
551
+ }
552
+ // #227: summarize the results the page shows, as they are: the page posts their text and only the summarize step
553
+ // runs on it (SYS1GREP_SUMMARIZE=stdin), no search and nothing to Jev. The query carries the search those
554
+ // results came from, so the summary is asked about its meanings. POST, so checked as the settings write is.
555
+ if (url.pathname === '/summarize') {
556
+ const answer = (code, x) => send(code, 'application/json', JSON.stringify(x));
557
+ if (req.method !== 'POST') return send(405, 'text/plain', 'POST');
558
+ if (req.headers.origin !== origin || ['cross-site', 'same-site'].includes(req.headers['sec-fetch-site'])) return send(403, 'text/plain', 'forbidden');
559
+ if (!/^text\/plain(;|$)/.test(req.headers['content-type'] ?? '')) return send(415, 'text/plain', 'text only');
560
+ const p = fromQuery(url.searchParams, init);
561
+ if (!p || p.x.some(f => f[0] === 'S')) return send(400, 'text/plain', 'bad value');
562
+ let text;
563
+ try { text = await body(req, POSTED_MAX); } catch { return req.tooLarge ? answer(413, { error: `the results on screen are more than ${POSTED_MAX / 1024} KB to summarize; narrow the search` }) : answer(400, { error: 'the results did not arrive whole' }); }
564
+ if (!text.trim()) return answer(400, { error: 'no results to summarize' });
565
+ const ac = new AbortController();
566
+ res.on('close', () => ac.abort());
567
+ const { code, out, err } = await run(['--color=never', ...toArgv({ ...p, summarize: true }, init), '--format=plain'], ac.signal, text, { ...searchEnv, SYS1GREP_SUMMARIZE: 'stdin' }, p.g ?? init.g);
568
+ return answer(200, code ? { error: err.trim() || `exit ${code}` } : { text: out });
569
+ }
570
+ const kind = url.pathname.slice(1);
571
+ if (!['results', 'dry'].includes(kind) || ['cross-site', 'same-site'].includes(req.headers['sec-fetch-site'])) return send(404, 'text/plain', 'not found');
572
+ const p = fromQuery(url.searchParams, init);
573
+ if (!p) return send(400, 'text/plain', 'bad value');
574
+ const ac = new AbortController();
575
+ res.on('close', () => ac.abort());
576
+ send(200, 'application/json', JSON.stringify(await search(p, kind, ac.signal)));
577
+ });
578
+ server.on('error', e => { console.error(`sys1grep: --serve: ${e.message}`); process.exit(2); });
579
+ server.listen(Number(port) || 0, '127.0.0.1', () => {
580
+ console.log(`${via?.origin ?? `http://127.0.0.1:${server.address().port}`}/?k=${token}`);
581
+ });
582
+ }
583
+
584
+ const json = x => JSON.stringify(x).replace(/</g, '\\u003c');
585
+ const esc = t => String(t).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
586
+ // The Target section: one target a line, the first SHOWN of them, then a "+N more" fold with the rest; or, while -g is
587
+ // on, git log in their place (show() swaps the two).
588
+ const SHOWN = 5;
589
+ const codes = list => list.map(t => `<li><code>${esc(t)}</code></li>`).join('');
590
+ const targetList = ({ list, cwd, none }, g) => `<div id="targets" title="${esc(`relative to ${cwd}`)}"><ul class="targets" id="tg-files"${g ? ' hidden' : ''}>${none ? `<li class="note">${esc(none)}</li>`
591
+ : codes(list.slice(0, SHOWN)) + (list.length > SHOWN ? `<li><details class="more"><summary>+${list.length - SHOWN} more</summary><ul class="targets">${codes(list.slice(SHOWN))}</ul></details></li>` : '')}</ul>`
592
+ + `<p class="note" id="tg-git"${g ? '' : ' hidden'}>git log's commits (-g)</p></div>`;
593
+ // a labelled control with its "?" hint (the same markup as rank's): the label, then the icon and its tooltip
594
+ // "more about X": the "?" button's accessible name, built from the label's own (already translated) text, so a
595
+ // translated label never needs a second, hand-kept key just for its hint button (client script has the same function).
596
+ const moreAbout = (lang, label) => (lang === 'ja' ? `${label}について` : `more about ${label}`);
597
+ const hg = (lang, name, label, hintKey) => `<div class="withhint hintgroup" id="hg-${name}">${label.replace(/<(select|input)/, `<$1 aria-describedby="hint-${name}"`)}<span class="hintctl"><button type="button" class="qbtn" id="q-${name}" aria-expanded="false" aria-controls="hint-${name}" aria-label="${esc(moreAbout(lang, label.replace(/<select.*?<\/select>/, '').replace(/<[^>]+>/g, '').trim()))}">?</button><small class="hint" id="hint-${name}" role="tooltip" data-i18n="${hintKey}">${t(lang, hintKey)}</small></span></div>`;
598
+ // The browser demo (#200, scripts/demo-build.mjs): this page with sys1grep itself running in the visitor's browser, on the
599
+ // bundled examples/ (-r examples, as the copied command says), Jev's answers read from a recording (--cache=only) and
600
+ // no settings. recorded: whether the build found the recording (cache.json); without it the page runs no search.
601
+ export const DEMO_LAUNCH = ['-r', 'examples'];
602
+ const demoNote = ({ recorded }, lang) => `<div id="demo-note" role="note" data-recorded="${recorded ? 1 : 0}"><p data-i18n-html="demoBody">${t(lang, 'demoBody')}</p>${recorded ? '' : `<p id="demo-missing" data-i18n="demoMissing">${esc(t(lang, 'demoMissing'))}</p>`}</div>`;
603
+ export const demoPage = ({ recorded }) => page({ init: initOf(DEMO_LAUNCH), launch: DEMO_LAUNCH, after: [], token: '', targets: { list: ['examples'], cwd: 'the demo folder, in this browser' }, suggest: [], fake: false, lang: null, demo: { recorded } });
604
+ const page = ({ init, launch, after, token, targets, suggest, fake, lang, demo, by = '' }) => {
605
+ const L = lang ?? 'en', tr = k => esc(t(L, k)), trh = (name, label, hintKey) => hg(L, name, label, hintKey);
606
+ return `<!doctype html>
607
+ <html lang="${L}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
608
+ <meta name="color-scheme" content="light dark">${demo ? '<meta name="robots" content="noindex"><link rel="icon" href="data:,">' : ''}<title>sys1grep${demo ? ' demo' : ''}</title>
609
+ <style>
610
+ :root { --page: #f0f6ff; --bg: #fff; --fg: #102447; --muted: #5a6e8c; --rule: #e4edf8; --border: #7589ab; --accent: #075cf5; --accent-ink: #fff; --link: #0754d4; --input-bg: #f5f9ff; --pill-bg: #eaf3ff; --pill-fg: #0754d4; --code: #03162e; --code-fg: #dbe7ff; --band: linear-gradient(130deg, #03152e 5%, #083475 65%, #0751bc); --go: linear-gradient(130deg, #1a66e0, #0047cc); --cmd-bg: linear-gradient(115deg, #071c3d, #103c7a); --warn-bg: #fff4d6; --warn-fg: #6b4400; --warn-border: #d99a00; --shadow: 0 8px 28px rgba(24,62,116,.07); }
611
+ @media (prefers-color-scheme: dark) { :root { --page: #0a1222; --bg: #111c30; --fg: #e3ecfb; --muted: #93a6c4; --rule: #22324f; --border: #64799e; --accent: #4d8dff; --accent-ink: #06132b; --link: #7aaaff; --input-bg: #16243d; --pill-bg: #16305a; --pill-fg: #b4d0ff; --code: #050d1c; --code-fg: #cdd9f0; --band: linear-gradient(130deg, #010a18 5%, #06244f 65%, #073a85); --go: linear-gradient(130deg, #1d68dc, #0a4fd0); --cmd-bg: linear-gradient(115deg, #050f22, #0c2a57); --warn-bg: #3a2c06; --warn-fg: #ffd970; --warn-border: #b58800; --shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.35); } }
612
+ * { box-sizing: border-box; }
613
+ body { margin: 0; background: var(--page); color: var(--fg); font: 14px/1.5 arial, "Hiragino Sans", "Noto Sans CJK JP", sans-serif; }
614
+ .card { background: var(--bg); border-radius: 16px; box-shadow: var(--shadow); margin: 24px auto; padding: 20px 24px; width: min(880px, calc(100% - 32px)); }
615
+ .title { display: flex; align-items: center; gap: 12px; margin: 0 0 10px; }
616
+ .title .icon { display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; background: var(--accent); border-radius: 10px; flex: none; }
617
+ .title h1 { font-size: 24px; margin: 0; font-weight: 800; flex: 1; }
618
+ .title .accent { color: var(--accent); }
619
+ .gear { margin-left: auto; border: none; background: none; color: var(--muted); padding: 6px; border-radius: 8px; display: inline-flex; flex: none; }
620
+ .gear:hover, .gear:focus-visible { background: var(--input-bg); color: var(--fg); }
621
+ .row { display: flex; gap: 6px; align-items: center; margin: 4px 0; flex-wrap: wrap; }
622
+ .row input[type=text] { flex: 1; min-width: 100px; padding: 6px 12px; font-size: 15px; border: 1px solid var(--border); border-radius: 10px; background: var(--input-bg); color: var(--fg); }
623
+ select, button, input { font: inherit; color: var(--fg); }
624
+ button, select { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 6px 12px; cursor: pointer; }
625
+ #go { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 700; font-size: 16px; padding: 10px 32px; border-radius: 12px; }
626
+ [hidden] { display: none !important; }
627
+ #fake-jev { margin: 0 0 10px; padding: 8px 12px; background: var(--warn-bg); color: var(--warn-fg); border: 1px solid var(--warn-border); border-radius: 10px; font-weight: 700; }
628
+ #est, #stop, #copy, #save { background: var(--input-bg); }
629
+ fieldset.group { border: 1px solid var(--border); border-radius: 10px; padding: 2px 10px 8px; margin: 4px 0; min-width: 0; }
630
+ .group legend.glabel { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 0; padding: 0 4px; }
631
+ .sec { margin: 0; padding: 6px 0; } .sec + .sec { border-top: 1px solid var(--rule); }
632
+ .sech { font-size: 13px; font-weight: 700; margin: 0 0 2px; }
633
+ .langsw { display: inline-flex; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; margin-left: 8px; }
634
+ .langsw button { border: 0; border-radius: 0; background: none; color: var(--muted); padding: 2px 10px; font-size: 12px; }
635
+ .langsw button[aria-pressed=true] { background: var(--input-bg); color: var(--fg); font-weight: 700; }
636
+ .langsw button:focus-visible { outline: 2px solid var(--fg); outline-offset: -3px; }
637
+ .targets { list-style: none; margin: 0; padding: 0; } #tg-git { margin: 0; }
638
+ details.more { margin: 0; background: none; } details.more > summary { padding: 0; color: var(--muted); }
639
+ main:has(#left:empty):not(.two) { display: none; } .targets code { font: 13px/1.6 ui-monospace, Menlo, monospace; overflow-wrap: anywhere; }
640
+ .row.edges { border-top: 1px solid var(--rule); margin-top: 10px; padding-top: 10px; }
641
+ .del { border: none; background: none; color: var(--muted); padding: 4px; border-radius: 8px; display: inline-flex; }
642
+ .del:hover { background: var(--input-bg); color: var(--fg); }
643
+ .err { color: #d93025; white-space: pre-wrap; } .note { color: var(--muted); }
644
+ .withhint { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
645
+ .hintctl { position: relative; display: inline-flex; }
646
+ .qbtn { width: 16px; height: 16px; border-radius: 50%; border: 1px solid var(--border); background: var(--input-bg); color: var(--muted); font-size: 10px; line-height: 1; padding: 0; display: inline-flex; align-items: center; justify-content: center; cursor: help; }
647
+ .qbtn:hover, .qbtn:focus-visible { color: var(--fg); border-color: var(--accent); }
648
+ .hint { position: absolute; z-index: 30; left: 0; top: 100%; margin-top: 6px; width: max-content; max-width: min(360px, calc(100vw - 32px)); padding: 8px 10px; background: var(--code); color: var(--code-fg); font-size: 11px; line-height: 1.5; white-space: pre-line; text-align: left; border-radius: 8px; box-shadow: var(--shadow); display: none; pointer-events: none; }
649
+ #hg-rank .hint, #hg-summarize .hint { left: auto; right: 0; }
650
+ #hg-step .hint, #hg-edges .hint { top: auto; bottom: 100%; margin-top: 0; margin-bottom: 6px; }
651
+ @media (hover: hover) { .hintctl:hover .hint { display: block; } }
652
+ .qbtn:focus-visible + .hint, .hintgroup.open .hint { display: block; }
653
+ @media (max-width: 480px) { .hint, #hg-step .hint, #hg-edges .hint, #hg-rank .hint, #hg-summarize .hint { position: fixed; left: 16px; right: 16px; bottom: 16px; top: auto; margin: 0; max-width: none; width: auto; } }
654
+ .dlgbody { padding: 20px 24px; }
655
+ dialog#sd { background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 16px; box-shadow: var(--shadow); width: min(560px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); overflow: auto; padding: 0; }
656
+ dialog#sd::backdrop { background: rgba(0, 0, 0, .45); }
657
+ .dlghead { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 10px; }
658
+ .dlghead h2 { font-size: 16px; margin: 0; }
659
+ .dlgx { border: none; background: none; color: var(--muted); font-size: 20px; line-height: 1; padding: 4px 8px; border-radius: 8px; }
660
+ .dlgx:hover, .dlgx:focus-visible { background: var(--input-bg); color: var(--fg); }
661
+ #cmd { flex: 1; margin: 0; padding: 10px 14px; background: var(--code); color: var(--code-fg); border-radius: 10px; font: 12px/1.5 ui-monospace, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
662
+ details { margin: 6px 0; background: var(--input-bg); border-radius: 10px; } summary { cursor: pointer; color: var(--muted); padding: 8px 12px; }
663
+ summary.dot::after { content: " \\25CF"; color: var(--link); }
664
+ .grid { display: grid; gap: 12px 20px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); padding: 4px 12px 12px; }
665
+ .grid .withhint { align-items: flex-start; } .grid .withhint > label { flex: 1; min-width: 0; }
666
+ .grid label { display: flex; flex-direction: column; gap: 2px; color: var(--muted); } .grid label.chk { flex-direction: row; align-items: center; gap: 6px; }
667
+ .grid input:not([type=checkbox]), .grid select, .grid textarea { padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--fg); width: 100%; font: inherit; }
668
+ main { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 4px 12px; margin: 12px 0 4px; display: grid; gap: 12px; grid-template-columns: 1fr; } main.two { grid-template-columns: 3fr 2fr; }
669
+ /* while a search runs (busy()): the page veiled, a ring in the middle of the screen. Both fade in after 250ms, so a search
670
+ answered at once (the demo's recording answers in under 100ms) never flashes them; the buttons and aria-busy change at once.
671
+ Nothing blocks the pointer: Stop stays clickable and the next search can be typed meanwhile */
672
+ main.busy::before, main.busy::after { content: ""; position: fixed; inset: 0; z-index: 5; pointer-events: none; animation: fade .15s .25s both; }
673
+ main.busy::before { background: var(--page); opacity: .6; }
674
+ main.busy::after { margin: auto; width: 52px; height: 52px; border-radius: 50%; background: var(--bg); box-shadow: var(--shadow); border: 5px solid var(--border); border-top-color: var(--accent); animation: fade .15s .25s both, spin .8s linear infinite; }
675
+ #stop:enabled { position: relative; z-index: 6; } /* above the veil: the one live control while a search runs */
676
+ @keyframes fade { from { opacity: 0; } } @keyframes spin { to { transform: rotate(360deg); } }
677
+ @media (prefers-reduced-motion: reduce) { main.busy::after { animation-duration: .15s, 3s; } }
678
+ @media (max-width: 880px) { main.two { grid-template-columns: 1fr; } }
679
+ iframe { width: 100%; border: 0; min-height: 80px; } pre.out { white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.5 ui-monospace, Menlo, monospace; }
680
+ .c32 { color: #188038; } .c35 { color: #a142f4; } .c36 { color: #0c7a88; } .c31 { color: #d93025; } .c33 { color: #b06000; } .c01_31 { color: #d93025; font-weight: 700; } .c01_33 { color: #b06000; font-weight: 700; background: #fff3b055; }\n@media (prefers-color-scheme: dark) { .c32 { color: #81c995; } .c35 { color: #d7aefb; } .c36 { color: #78d9ec; } .c31, .c01_31 { color: #f28b82; } .c33, .c01_33 { color: #fdd663; } .err { color: #f28b82; } }\nbutton:disabled { opacity: .45; cursor: default; } #ex button { border-radius: 999px; background: var(--pill-bg); color: var(--pill-fg); border: none; } #ex .label, #ex .grp { flex-basis: 100%; font-weight: 700; color: var(--muted); margin-top: 4px; }
681
+ #right { border-left: 1px solid var(--rule); padding-left: 16px; }
682
+ body { background: var(--band) top / 100% 64px no-repeat, var(--page); }
683
+ .card { background: none; box-shadow: none; border-radius: 0; padding: 0 0 24px; margin: 0 auto; width: min(1160px, calc(100% - 32px)); }
684
+ .title { height: 64px; margin: 0; color: #fff; gap: 10px; }
685
+ .title .icon { width: 36px; height: 36px; border-radius: 10px; background: linear-gradient(140deg, #328bff, #0050df); box-shadow: 0 4px 22px rgba(0,114,255,.31); }
686
+ .title .icon svg { width: 20px; height: 20px; }
687
+ .title h1 { font-size: 24px; letter-spacing: -.5px; } .title .accent { color: #6aa8ff; }
688
+ .gear { color: #d5e5ff; border: 1px solid #7f9fca; padding: 5px; } .gear:hover, .gear:focus-visible { background: rgba(255,255,255,.12); color: #fff; }
689
+ #fake-jev { margin: 12px 0 0; }
690
+ #demo-note { margin: 12px 0 0; padding: 10px 14px; background: var(--bg); border: 1px solid var(--accent); border-left-width: 6px; border-radius: 10px; } #demo-note p { margin: 0; } #demo-note p + p { margin-top: 4px; } #demo-note code { font: 13px ui-monospace, Menlo, monospace; } #demo-missing { font-weight: 700; color: var(--warn-fg); }
691
+ .kcard { background: var(--bg); border: 1px solid var(--rule); border-radius: 12px; padding: 12px 16px; box-shadow: var(--shadow); margin-top: 12px; }
692
+ .search-layout { display: grid; grid-template-columns: minmax(0, 1fr) 220px; gap: 16px; }
693
+ .actions { border-left: 1px solid var(--rule); padding-left: 16px; display: flex; flex-direction: column; justify-content: center; gap: 8px; }
694
+ #go { width: 100%; min-height: 40px; border: 0; border-radius: 10px; background: var(--go); color: #fff; font-size: 16px; font-weight: 700; box-shadow: 0 7px 16px rgba(20,105,250,.15); }
695
+ .actions .row { margin: 0; flex-wrap: nowrap; } .actions .row > button { flex: 1; padding: 6px; white-space: nowrap; }
696
+ .columns { margin-top: 12px; } .columns .kcard { padding: 12px 16px; margin-top: 0; }
697
+ #ex { gap: 6px; margin: 0; } #ex button { padding: 3px 10px; }
698
+ #ex .label { font-size: 15px; color: var(--fg); margin: 0; }
699
+ #ex .grp { border-top: 1px solid var(--rule); padding-top: 6px; margin-top: 2px; color: var(--fg); }
700
+ .title .langsw { border-color: #7f9fca; }
701
+ .title .langsw button { color: #d5e5ff; }
702
+ .title .langsw button[aria-pressed=true] { background: rgba(255,255,255,.18); color: #fff; }
703
+ .title .langsw button:focus-visible { outline-color: #fff; }
704
+ #sec-opts { border-top: 1px solid var(--rule); padding-top: 8px; } #sec-opts .sech { font-size: 15px; } #sec-opts .row { flex-wrap: wrap; }
705
+ .command { background: var(--cmd-bg); color: #fff; border-radius: 12px; padding: 10px 16px; margin-top: 12px; }
706
+ .command .sech { color: #fff; } .command #cmd { background: #03162e; color: #dbe7ff; border: 1px solid #7799ca; } #cmd-demo-note { flex-basis: 100%; } /* its own line under the box, never squeezing #cmd */
707
+ .command #copy { background: transparent; color: #fff; border-color: #7f9fca; } .command .note, .command .note code { color: #c9dbf7; }
708
+ #d { margin-top: 12px; } main { margin-top: 12px; border-color: var(--rule); border-radius: 12px; box-shadow: var(--shadow); }
709
+ @media (max-width: 850px) { .search-layout { grid-template-columns: 1fr; } .actions { border-left: 0; border-top: 1px solid var(--rule); padding: 10px 0 0; } }
710
+ @media (max-width: 520px) { .title h1 { font-size: 20px; } .title .icon { width: 32px; height: 32px; } .kcard { padding: 10px 12px; } }
711
+ </style></head><body>
712
+ <div class="card" id="card">
713
+ <div class="title" id="title"><span class="icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="20" height="20"><circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="#fff" stroke-width="2.2"/><line x1="15.3" y1="15.3" x2="20.5" y2="20.5" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/></svg></span><h1>sys<span class="accent">1grep</span></h1><span class="langsw" role="group" aria-label="page language"><button type="button" id="lang-en" lang="en" aria-pressed="${L === 'en' ? 'true' : 'false'}">EN</button><button type="button" id="lang-ja" lang="ja" aria-pressed="${L === 'ja' ? 'true' : 'false'}">日本語</button></span><button type="button" class="gear" id="gear" title="${tr('settings')}" aria-label="${tr('settings')}" data-i18n-title="settings" data-i18n-aria-label="settings"${demo ? ' hidden' : ''}><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg></button></div>
714
+ <p id="fake-jev" role="note" data-i18n="fakeJev"${fake ? '' : ' hidden'}>${tr('fakeJev')}</p>${demo ? demoNote(demo, L) : ''}
715
+ <form id="f" autocomplete="off" onsubmit="return false">
716
+ <div class="kcard search-layout" id="search"><div class="fields">
717
+ <section class="sec" id="sec-target" aria-labelledby="h-target"><h2 class="sech" id="h-target" title="${tr('targetHint')}" data-i18n="target" data-i18n-title="targetHint">${tr('target')}</h2>
718
+ ${targetList(targets, init.g)}</section>
719
+ <section class="sec" id="sec-cond" aria-labelledby="h-cond"><h2 class="sech" id="h-cond" data-i18n="cond">${tr('cond')}</h2>
720
+ <fieldset class="group" id="startgrp"><legend class="glabel" title="${tr('startLegendHint')}" data-i18n="startLegend" data-i18n-title="startLegendHint">${tr('startLegend')}</legend><div id="fields"></div><button type="button" id="add" title="${tr('addStartTitle')}" aria-label="${tr('addStartTitle')}" data-i18n="addStart" data-i18n-title="addStartTitle" data-i18n-aria-label="addStartTitle">${tr('addStart')}</button></fieldset>
721
+ <div class="row"><div class="withhint hintgroup" id="hg-step"><label><input type="checkbox" id="step" aria-describedby="hint-step"> <span data-i18n="multiStep">${tr('multiStep')}</span></label><span class="hintctl"><button type="button" class="qbtn" id="q-step" aria-expanded="false" aria-controls="hint-step" aria-label="${esc(moreAbout(L, t(L, 'multiStep')))}">?</button><small class="hint" id="hint-step" role="tooltip" data-i18n="multiStepHint">${tr('multiStepHint')}</small></span></div></div>
722
+ <fieldset class="group" id="steps" hidden><legend class="glabel" data-i18n="endLegend">${tr('endLegend')}</legend><div id="ends"></div><button type="button" id="addend" title="${tr('addEndTitle')}" aria-label="${tr('addEndTitle')}" data-i18n="addEnd" data-i18n-title="addEndTitle" data-i18n-aria-label="addEndTitle">${tr('addEnd')}</button>
723
+ <div class="row edges hintgroup" id="hg-edges"><span class="note" data-i18n="edgesLabel">${tr('edgesLabel')}</span><span class="hintctl"><button type="button" class="qbtn" id="q-edges" aria-expanded="false" aria-controls="hint-edges" aria-label="${esc(moreAbout(L, t(L, 'edgesLabel')))}">?</button><small class="hint" id="hint-edges" role="tooltip" data-i18n="edgesHint">${tr('edgesHint')}</small></span><label title="${tr('hopsTitle')}" data-i18n-title="hopsTitle">--hops <input type="text" name="hops" size="6" aria-describedby="hint-edges"></label><label><input type="checkbox" name="reverse" aria-describedby="hint-edges"> <span data-i18n="reverseLabel">${tr('reverseLabel')}</span></label></div></fieldset>
724
+ </section></div>
725
+ <div class="actions" id="actions"><button type="submit" id="go" data-i18n="go">${tr('go')}</button><div class="row"><button type="button" id="est" title="${tr('estTitle')}" data-i18n="est" data-i18n-title="estTitle">${tr('est')}</button><button type="button" id="stop" title="${tr('stopTitle')}" data-i18n="stop" data-i18n-title="stopTitle" disabled>${tr('stop')}</button></div>
726
+ <section class="sec" id="sec-opts" aria-labelledby="h-opts"><h2 class="sech" id="h-opts" data-i18n="opts">${tr('opts')}</h2>
727
+ <div class="row">
728
+ <div class="withhint hintgroup" id="hg-rank"><label><span data-i18n="rank">${tr('rank')}</span> <select name="rank" aria-describedby="hint-rank"><option value="" data-i18n="rankOff">${tr('rankOff')}</option><option value="jev">jev</option><option value="match">match</option></select></label><span class="hintctl"><button type="button" class="qbtn" id="q-rank" aria-expanded="false" aria-controls="hint-rank" aria-label="${esc(moreAbout(L, t(L, 'rank')))}">?</button><small class="hint" id="hint-rank" role="tooltip" data-i18n="rankHint">${tr('rankHint')}</small></span></div>
729
+ <div class="withhint hintgroup" id="hg-summarize"><label><input type="checkbox" name="summarize" aria-describedby="hint-summarize"> <span data-i18n="summarize">${tr('summarize')}</span></label><span class="hintctl"><button type="button" class="qbtn" id="q-summarize" aria-expanded="false" aria-controls="hint-summarize" aria-label="${esc(moreAbout(L, t(L, 'summarize')))}">?</button><small class="hint" id="hint-summarize" role="tooltip" data-i18n="summarizeHint">${tr('summarizeHint')}</small></span></div>
730
+ <input type="text" name="summarize-prompt" placeholder="${tr('summarizePrompt')}" data-i18n-placeholder="summarizePrompt" style="display:none;flex:1;min-width:160px">
731
+ </div>
732
+ <small class="note" id="sum-by">${esc(by)}</small>
733
+ <div class="row ask" id="ask" role="group" aria-labelledby="askq" hidden><span id="askq"></span><button type="button" id="ask-ok">OK</button><button type="button" id="ask-no">Not now</button></div>
734
+ </section></div>
735
+ </div>
736
+ <div class="columns">
737
+ <section class="sec kcard" id="sec-ex" aria-label="examples"><div class="row" id="ex"><span class="note label" data-i18n="examples">${tr('examples')}</span></div>
738
+ </section>
739
+ </div>
740
+ <section class="command" id="sec-cmd" aria-labelledby="h-cmd"><h2 class="sech" id="h-cmd" data-i18n="command">${tr('command')}</h2>
741
+ <div class="row"><pre id="cmd" title="${tr('cmdTitle')}" data-i18n-title="cmdTitle"></pre><button type="button" id="copy" title="${tr('copyTitle')}" data-i18n="copy" data-i18n-title="copyTitle">${tr('copy')}</button>${targets.demo ? `<span class="note" id="cmd-demo-note" data-i18n-html="cmdDemoNote">${t(L, 'cmdDemoNote')}</span>` : ''}</div>
742
+ </section>
743
+ <details id="d"><summary id="ds" title="${tr('detailsTitle')}" data-i18n="details" data-i18n-title="detailsTitle">${tr('details')}</summary><div class="grid">
744
+ ${trh('level', `<label><span data-i18n="level">${tr('level')}</span> <select name="level"><option>loose</option><option>normal</option><option>strict</option></select></label>`, 'levelHint')}
745
+ ${trh('t', '<label>-t <input name="t" size="5" inputmode="decimal"></label>', 'tHint')}${trh('T', '<label>-T <input name="T" size="5" inputmode="decimal"></label>', 'THint')}
746
+ ${trh('C', `<label><span data-i18n="cLabel">${tr('cLabel')}</span> <input name="C" size="3" inputmode="numeric"></label>`, 'cHint')}
747
+ ${trh('A', '<label>-A <input name="A" size="3" inputmode="numeric"></label>', 'aHint')}${trh('B', '<label>-B <input name="B" size="3" inputmode="numeric"></label>', 'bHint')}
748
+ ${trh('autoscope', `<label class="chk"><input type="checkbox" name="auto-scope"> <span data-i18n="autoscope">${tr('autoscope')}</span></label>`, 'autoscopeHint')}
749
+ ${trh('n', `<label class="chk"><input type="checkbox" name="n"> <span data-i18n="nLabel">${tr('nLabel')}</span></label>`, 'nHint')}${trh('p', `<label class="chk"><input type="checkbox" name="p"> <span data-i18n="pLabel">${tr('pLabel')}</span></label>`, 'pHint')}
750
+ ${trh('dedup', `<label><span data-i18n="dedup">${tr('dedup')}</span> <select name="dedup"><option>never</option><option>auto</option><option>always</option></select></label>`, 'dedupHint')}
751
+ ${trh('unit', `<label><span data-i18n="unit">${tr('unit')}</span> <select name="unit"><option>line</option><option>sentence-by-jev</option><option>sentence-by-rule</option><option>function</option></select></label>`, 'unitHint')}
752
+ ${trh('o', `<label class="chk"><input type="checkbox" name="o"> <span data-i18n="oLabel">${tr('oLabel')}</span></label>`, 'oHint')}
753
+ ${trh('j', '<label>-j <input name="j" size="3" inputmode="numeric"></label>', 'jHint')}${trh('maxcost', '<label>--max-cost <input name="max-cost" size="5" inputmode="decimal"></label>', 'maxcostHint')}
754
+ ${trh('include', '<label>--include <input name="include"></label>', 'includeHint')}${trh('exclude', '<label>--exclude <input name="exclude"></label>', 'excludeHint')}
755
+ ${trh('changed', `<label>--changed-within <input name="changed-within" placeholder="2h, 7d, a date"></label>`, 'changedHint')}${trh('g', `<label class="chk"><input type="checkbox" name="g"> <span data-i18n="gLabel">${tr('gLabel')}</span></label>`, 'gHint')}
756
+ </div></details></form>
757
+ <main id="m"><div id="left" title="results"></div><div id="right" title="the summary of the results" hidden></div></main>
758
+ <dialog id="sd" aria-labelledby="sdh"><div class="dlgbody"><div class="dlghead"><h2 id="sdh" data-i18n="settings">${tr('settings')}</h2><button type="button" class="dlgx" id="sdx" aria-label="${tr('close')}" title="${tr('close')}" data-i18n-aria-label="close" data-i18n-title="close">&times;</button></div><form id="sf" autocomplete="off" onsubmit="return false"><div class="grid">
759
+ <label>url <input name="set-url" placeholder="https://api.typesafe.ai/v1/systemone" autofocus><small class="note" id="src-url"></small></label>
760
+ <label><span data-i18n="fieldModel">${tr('fieldModel')}</span> <input name="set-model" placeholder="jev-latest"><small class="note" id="src-model"></small></label>
761
+ <label><span data-i18n="fieldKey">${tr('fieldKey')}</span> <input type="password" name="set-key" autocomplete="new-password"><small class="note" id="src-key"></small><span class="chk"><input type="checkbox" name="clear-key"> <span data-i18n="remove">${tr('remove')}</span></span></label>
762
+ <label><span data-i18n="fieldOpts">${tr('fieldOpts')}</span> <textarea name="set-opts" rows="3"></textarea><small class="note" id="src-opts"></small></label>
763
+ <label><span data-i18n="fieldSummarizer">${tr('fieldSummarizer')}</span> <input name="set-summarizer" placeholder="claude"><small class="note" id="src-summarizer"></small></label>
764
+ <label><span data-i18n="fieldSummarizerModel">${tr('fieldSummarizerModel')}</span> <input name="set-summarizerModel"><small class="note" id="src-summarizerModel"></small></label>
765
+ <label><span data-i18n="fieldSummarizerKey">${tr('fieldSummarizerKey')}</span> <input type="password" name="set-summarizerKey" autocomplete="new-password"><small class="note" id="src-summarizerKey"></small><span class="chk"><input type="checkbox" name="clear-summarizerKey"> <span data-i18n="remove">${tr('remove')}</span></span></label>
766
+ <label><span data-i18n="fieldLang">${tr('fieldLang')}</span> <select name="set-lang"><option value="">-</option><option value="en">English</option><option value="ja">日本語</option></select><small class="note" id="src-lang"></small></label>
767
+ </div><div class="row"><button type="button" id="save" data-i18n="save">${tr('save')}</button><span class="note" id="sst"></span></div></form></div></dialog>
768
+ </div>
769
+ ${demo ? '<script src="demo.js"></script>' : ''}
770
+ <script>
771
+ const CONTROLS =${json(CONTROLS)}, INIT = ${json(init)}, LAUNCH = ${json(launch)}, AFTER = ${json(after)}, TOKEN = ${json(token)}, EXAMPLES = ${json(EXAMPLES)}, SUGGEST = ${json(suggest)};
772
+ const toArgv = ${toArgv};
773
+ const searchArgs = ${searchArgs}, answerOf = ${answerOf}, TPL = ${json(tplOf(launch))};
774
+ // The transport: --serve's own server, or on the demo page (demo.js, #200) sys1grep itself in a Web Worker and the
775
+ // browser's own summarizer; the arguments and the answer are built the same way for both.
776
+ const answer = window.sys1demo
777
+ ? async (kind, p, post, signal) => (kind === 'summarize' ? sys1demo.summarize(post.body, p, signal) : answerOf(p, kind, INIT, await sys1demo.run([...LAUNCH, ...searchArgs(p, kind, INIT, TPL), ...AFTER], signal)))
778
+ : async (kind, p, post, signal) => { const r = await fetch('/' + kind + '?' + query(p), { ...post, signal }); return r.json().catch(() => ({ error: 'bad answer' })); };
779
+ const costRetryOf = ${costRetryOf};
780
+ const quote = (s, force) => (!force && /^[\\w@%+=:,./-]+$/.test(s) ? s : "'" + s.replace(/'/g, "'\\\\''") + "'");
781
+ const shown = argv => argv.map((t, i) => (/^-[eavQP]$/.test(argv[i - 1] ?? '') ? quote(t, t[0] === '-') : /^--[a-z-]+=/.test(t) ? t.replace(/=([\\s\\S]*)/, (_, v) => '=' + quote(v)) : quote(t)));
782
+ // #270: the whole page's language. Precedence: localStorage (this browser) > the launch/env/settings default the
783
+ // server rendered this page in (LANG_INIT, null if none) > navigator.language > en. A toggle click or a settings
784
+ // save (fillSettings, below) can change it after load; applyI18n() re-renders every [data-i18n*] element in place.
785
+ const STRINGS = ${json(STRINGS)}, LANG_INIT = ${json(lang)};
786
+ let lang = LANG_INIT;
787
+ try { const saved = localStorage.getItem('lang'); if (saved === 'en' || saved === 'ja') lang = saved; } catch {}
788
+ if (!lang) lang = navigator.language && navigator.language.startsWith('ja') ? 'ja' : 'en';
789
+ const t = key => STRINGS[key]?.[lang === 'ja' ? 1 : 0] ?? STRINGS[key]?.[0] ?? key;
790
+ const moreAbout = label => (lang === 'ja' ? label + 'について' : 'more about ' + label);
791
+ const applyI18n = () => {
792
+ document.documentElement.lang = lang;
793
+ for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
794
+ for (const el of document.querySelectorAll('[data-i18n-html]')) el.innerHTML = t(el.dataset.i18nHtml);
795
+ for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle);
796
+ for (const el of document.querySelectorAll('[data-i18n-placeholder]')) el.placeholder = t(el.dataset.i18nPlaceholder);
797
+ for (const el of document.querySelectorAll('[data-i18n-aria-label]')) el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel));
798
+ // the "?" hint buttons: their accessible name is derived from the (now re-rendered) label text beside them, not
799
+ // kept as a second translated string per hint (the label is the single source of truth for both). The label word
800
+ // itself is always the first [data-i18n] in its group, before the hint's own (also [data-i18n]) small -- never the
801
+ // whole <label>, which for rank/level/dedup/unit would otherwise drag in its <select>'s option text too.
802
+ for (const b of document.querySelectorAll('.hintctl .qbtn')) {
803
+ const label = [...(b.closest('.withhint, .hintgroup')?.querySelectorAll('[data-i18n]') ?? [])].find(e => !e.getAttribute('id')?.startsWith('hint-'))?.textContent.trim(); // a literal label (-t) has none: keep the server's
804
+ if (label) b.setAttribute('aria-label', moreAbout(label));
805
+ }
806
+ // the start/end term-type selects (-e meaning, -P regex, ...): built client-side, so not [data-i18n] themselves.
807
+ for (const r of [...(fields?.children ?? []), ...(ends?.children ?? [])]) retag(r);
808
+ };
809
+ const f = document.getElementById('f'), fields = document.getElementById('fields'), cmd = document.getElementById('cmd');
810
+ const step = document.getElementById('step'), steps = document.getElementById('steps'), ends = document.getElementById('ends');
811
+ const ALL = [...CONTROLS, { k: 'summarize', bool: true }, { k: 'rank' }, { k: 'summarize-prompt' }];
812
+ const setControls = v => { for (const c of ALL) { const el = f.elements[c.k], x = v[c.k] ?? INIT[c.k]; if (c.bool) el.checked = !!x; else el.value = x; } };
813
+ const labels = () => ({ e: t('termE'), P: t('termP'), a: t('termA'), v: t('termV'), Q: t('termQ') });
814
+ // A close icon, not text, so the button's accessible name comes from aria-label alone, in every browser.
815
+ const DEL_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" fill="none"/></svg>';
816
+ const tips = () => ({
817
+ Q: [t('tipQph'), t('tipQhint')], e: [t('tipEph'), t('tipEhint')], P: ['/regex/flags', t('tipPhint')],
818
+ a: [t('tipAph'), t('tipAhint')], v: [t('tipVph'), t('tipVhint')],
819
+ });
820
+ const tip = r => { const [ph, hint] = tips()[r.firstChild.value]; r.firstChild.title = r.children[1].title = hint; r.children[1].placeholder = ph; };
821
+ const retag = r => { for (const [k, l] of Object.entries(labels())) r.firstChild.querySelector('option[value="' + k + '"]').textContent = l; tip(r); };
822
+ const addField = (k = 'Q', val = '', into = fields) => {
823
+ const r = document.createElement('div'); r.className = 'row';
824
+ r.innerHTML = '<select>' + Object.entries(labels()).map(([k, l]) => '<option value="' + k + '">' + l + '</option>').join('') + '</select><input type="text"><button type="button"></button>';
825
+ r.firstChild.value = k; r.children[1].value = val;
826
+ tip(r); r.firstChild.onchange = () => { tip(r); show(); };
827
+ r.children[1].oninput = show; r.children[1].onkeydown = e => { if (e.key === 'Enter') go(); };
828
+ const x = r.children[2];
829
+ x.className = 'del'; x.innerHTML = DEL_ICON; x.ariaLabel = t('remove'); x.title = t('remove');
830
+ // every row gets this button; on the one row left in a group it clears the row instead of removing it, so a
831
+ // group (start, or end while multi-step is on) is never left without a row to fill back in.
832
+ x.onclick = () => { if (into.children.length > 1) r.remove(); else r.children[1].value = ''; show(); };
833
+ into.append(r); show(); return r;
834
+ };
835
+ const terms = box => [...box.children].map(r => r.firstChild.value + ':' + r.children[1].value.trim()).filter(x => x.length > 2);
836
+ const read = () => {
837
+ const p = { x: [...terms(fields), ...(step.checked ? ['S:', ...terms(ends)] : [])] };
838
+ for (const c of ALL) { const el = f.elements[c.k]; p[c.k] = c.bool ? el.checked : el.value; }
839
+ return p;
840
+ };
841
+ const show = () => {
842
+ const p = read(), multi = p.x.some(x => x[0] === 'S');
843
+ // a numbered, distinct accessible name per row ("remove start condition 2"), renumbered on every change so a
844
+ // deletion in the middle doesn't leave a stale number on the rows after it.
845
+ for (const [box, key] of [[fields, 'termStart'], [ends, 'termEnd']]) {
846
+ const label = t(key);
847
+ [...box.children].forEach((r, i) => { const d = r.children[2]; if (d) d.title = d.ariaLabel = lang === 'ja' ? label + '条件 ' + (i + 1) + ' を削除' : 'remove ' + label + ' condition ' + (i + 1); });
848
+ }
849
+ steps.hidden = !multi;
850
+ const g = p.g && !multi; // -g does not apply to a walk
851
+ document.getElementById('tg-files').hidden = g;
852
+ document.getElementById('tg-git').hidden = !g;
853
+ f.elements.rank.disabled = multi;
854
+ f.elements.summarize.disabled = multi || !!window.sys1demo?.noSummary; // the demo, in a browser with no summarizer
855
+ f.elements['summarize-prompt'].style.display = p.summarize && !multi ? '' : 'none';
856
+ // #234: -o is useful only with a sentence unit (owner, #211 item 10); hidden otherwise, like steps for multi-step.
857
+ document.getElementById('hg-o').hidden = !/^sentence-by-/.test(p.unit);
858
+ cmd.textContent = ['sys1grep', ...LAUNCH.map(t => quote(t)), ...shown(toArgv(p, INIT)), ...AFTER.map(t => quote(t))].join(' ');
859
+ if (!ask.hidden) drawAsk();
860
+ document.getElementById('ds').className = CONTROLS.some(c => !['rank'].includes(c.k) && !c.step && p[c.k] !== INIT[c.k]
861
+ && !(c.nostep && multi) && !(c.sentenceOnly && !/^sentence-by-/.test(p.unit))) ? 'dot' : '';
862
+ if (!running) busy(false);
863
+ return p;
864
+ };
865
+ const query = p => { const q = new URLSearchParams({ k: TOKEN }); for (const x of p.x) q.append('x', x); for (const [k, v] of Object.entries(p)) if (k !== 'x') q.set(k, v === true ? '1' : v === false ? '0' : v); return q; };
866
+ const ansi = (text, into) => { // the SGR colors of --color=always, as spans; nothing but text goes in
867
+ let cls = '';
868
+ for (const part of text.split(/(\\x1b\\[[\\d;]*m)/)) {
869
+ const m = /^\\x1b\\[([\\d;]*)m$/.exec(part);
870
+ if (m) cls = m[1] === '0' || m[1] === '' ? '' : 'c' + m[1].replace(/;/g, '_');
871
+ else if (part) { const s = document.createElement('span'); s.className = cls; s.textContent = part; into.append(s); }
872
+ }
873
+ };
874
+ let gen = 0;
875
+ let ctl = null, running = 0;
876
+ // Search and Estimate cost wait for a field to search for (and, on the demo, for its recording). A search that starts
877
+ // below the fold scrolls the results into view, so the answer is never written off screen.
878
+ const ready = () => !window.sys1demo?.noRun && read().x.some(x => x[0] !== 'S');
879
+ const busy = on => {
880
+ for (const id of ['go', 'est']) document.getElementById(id).disabled = on || !ready();
881
+ document.getElementById('stop').disabled = !on;
882
+ const m = document.getElementById('m'); m.classList.toggle('busy', on); m.setAttribute('aria-busy', String(on));
883
+ if (on) setTimeout(intoView); // after the caller's turn, once it has written "searching..." (an empty #m has no box to measure)
884
+ };
885
+ const intoView = () => {
886
+ const m = document.getElementById('m');
887
+ if (m.getBoundingClientRect?.().top > window.innerHeight * 0.6) m.scrollIntoView({ block: 'start', behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
888
+ };
889
+ const get = async (kind, p, into, post) => {
890
+ const mine = gen;
891
+ running++;
892
+ try {
893
+ const j = await answer(kind, p, post, ctl.signal);
894
+ if (mine === gen) await show1(j, into); // an html result's iframe loads async; wait for it, so the caller's text is ever the shown text (#231)
895
+ if (mine === gen && kind === 'results' && (j.text || j.html) && !p.x.some(x => x[0] === 'S')) onScreen = p;
896
+ return j;
897
+ } catch (e) {
898
+ if (mine === gen) show1({ error: String(e) }, into);
899
+ return { error: String(e) };
900
+ } finally {
901
+ running = Math.max(0, running - 1);
902
+ if (running === 0 && mine === gen) busy(false);
903
+ }
904
+ };
905
+ // returns a promise that resolves once into holds the shown content: immediately, except for an iframe (j.html),
906
+ // whose srcdoc loads asynchronously -- a caller that reads the shown text (the summarize POST, #231) must await it.
907
+ const show1 = (j, into) => {
908
+ into.textContent = '';
909
+ if (j.error) { const e = document.createElement('div'); e.className = 'err'; e.textContent = j.error; into.append(e); return; }
910
+ if (j.none) { const e = document.createElement('div'); e.className = 'note'; e.textContent = t('noMatches') + (j.note ? ' (' + j.note + ')' : ''); into.append(e); return; }
911
+ if (j.html) {
912
+ const i = document.createElement('iframe'); i.sandbox = 'allow-same-origin'; i.srcdoc = j.html;
913
+ const ready = new Promise(r => { i.onload = () => { i.style.height = i.contentDocument.documentElement.scrollHeight + 'px'; r(); }; });
914
+ into.append(i);
915
+ return ready;
916
+ }
917
+ const e = document.createElement('pre'); e.className = 'out'; ansi(j.text, e); into.append(e);
918
+ };
919
+ document.getElementById('stop').onclick = () => {
920
+ ctl.abort(); gen++; running = 0; busy(false);
921
+ for (const el of [document.getElementById('left'), document.getElementById('right')]) if (!el.hidden && /\.\.\.$/.test(el.textContent)) el.textContent = t('stopped');
922
+ };
923
+ // costRetryOf (shared, above): the bump a "Search anyway" button should use, or null for none (no match, or a
924
+ // multi-step walk). Offers a button that reruns this one search, once, with --max-cost at that bump -- not a
925
+ // change to the Details field, so the next search still uses what was there before.
926
+ const addCostRetry = (j, p, into) => {
927
+ const bump = costRetryOf(p, j.error);
928
+ if (!bump) return;
929
+ const b = document.createElement('button'); b.type = 'button'; b.textContent = lang === 'ja' ? '--max-cost ' + bump + ' で' + t('searchAnyway') : t('searchAnyway') + ' (--max-cost ' + bump + ')';
930
+ b.onclick = () => {
931
+ if (running) return;
932
+ gen++; ctl = new AbortController(); busy(true); into.textContent = t('searching');
933
+ get('results', { ...p, 'max-cost': bump }, into);
934
+ };
935
+ into.append(document.createElement('br'), b);
936
+ };
937
+ const run = kind => {
938
+ const p = show(), left = document.getElementById('left'), right = document.getElementById('right');
939
+ if (running) return;
940
+ const s = p.x.indexOf('S:');
941
+ if (s === 0 || !p.x.length) { left.textContent = t('enterMeaning'); return; }
942
+ if (!ready()) return; // Enter in a field must not run what the disabled Search would not
943
+ if (s === p.x.length - 1) { left.textContent = t('enterEnd'); return; }
944
+ gen++; ctl = new AbortController(); busy(true); onScreen = null; ask.hidden = true;
945
+ left.textContent = t('searching');
946
+ const sum = kind === 'results' && p.summarize && !p.x.some(x => x[0] === 'S');
947
+ document.getElementById('m').classList.toggle('two', !!sum);
948
+ right.hidden = !sum; right.textContent = sum ? t('summarizing') : '';
949
+ const mine = gen;
950
+ // #231: one search, not two. Reserve the summarize step's slot so busy(false) does not fire in the gap between the
951
+ // results arriving and the summarize request starting; get('summarize', ...) below takes over the reservation (both
952
+ // happen in the same synchronous turn, so running never drops to 0 in between).
953
+ if (sum) running++;
954
+ get(kind, p, left).then(j => {
955
+ if (kind === 'results' && mine === gen) addCostRetry(j, p, left);
956
+ if (!sum) return;
957
+ if (mine !== gen) { running = Math.max(0, running - 1); return; }
958
+ if (!(j.text || j.html)) { show1(j, right); running = Math.max(0, running - 1); if (running === 0) busy(false); return; }
959
+ running--;
960
+ const frame = left.querySelector('iframe');
961
+ const text = frame ? frame.contentDocument.body.innerText : left.textContent;
962
+ get('summarize', { ...p, 'summarize-prompt': read()['summarize-prompt'] }, right, { method: 'POST', headers: { 'content-type': 'text/plain' }, body: text });
963
+ });
964
+ };
965
+ const go = () => run('results');
966
+ // #227: checking summarize while results are on screen asks whether to summarize those results as they are. OK posts
967
+ // their text (what the page shows) and only the summarizer runs; Not now leaves the box checked for the next Search.
968
+ // The search they came from is kept, so a field or control changed since is said in the question.
969
+ let onScreen = null;
970
+ const ask = document.getElementById('ask'), askq = document.getElementById('askq'), askOk = document.getElementById('ask-ok'), askNo = document.getElementById('ask-no');
971
+ const ASK = {
972
+ en: ['Summarize the results on screen as they are?', 'The results on screen are from before the fields or options changed. Summarize them as they are anyway?', 'OK', 'Not now'],
973
+ ja: ['表示中の結果をそのまま要約しますか?', '表示中の結果は、欄やオプションを変える前のものです。それでもそのまま要約しますか?', 'OK', '今はしない'],
974
+ };
975
+ const searchOf = p => { const q = query(p); q.delete('summarize'); q.delete('summarize-prompt'); return String(q); };
976
+ const drawAsk = () => {
977
+ const p = read(), [q, stale, ok, no] = ASK[lang];
978
+ if (!p.summarize || !onScreen || running || p.x.some(x => x[0] === 'S')) { ask.hidden = true; return; }
979
+ ask.lang = lang; askq.textContent = searchOf(p) === searchOf(onScreen) ? q : stale; askOk.textContent = ok; askNo.textContent = no;
980
+ };
981
+ f.elements.summarize.addEventListener('change', () => { ask.hidden = !f.elements.summarize.checked; drawAsk(); });
982
+ askNo.onclick = () => { ask.hidden = true; };
983
+ askOk.onclick = () => {
984
+ if (!onScreen || running) return;
985
+ const left = document.getElementById('left'), right = document.getElementById('right'), frame = left.querySelector('iframe');
986
+ const text = frame ? frame.contentDocument.body.innerText : left.textContent;
987
+ ask.hidden = true;
988
+ gen++; ctl = new AbortController(); busy(true);
989
+ document.getElementById('m').classList.add('two'); right.hidden = false; right.textContent = t('summarizing');
990
+ get('summarize', { ...onScreen, 'summarize-prompt': read()['summarize-prompt'] }, right, { method: 'POST', headers: { 'content-type': 'text/plain' }, body: text });
991
+ };
992
+ f.onsubmit = e => { e.preventDefault(); go(); return false; };
993
+ document.getElementById('est').onclick = () => run('dry');
994
+ document.getElementById('add').onclick = () => addField('a').children[1].focus();
995
+ document.getElementById('addend').onclick = () => addField('e', '', ends).children[1].focus();
996
+ // The whole page, and the examples with it (#215, #270), in English or Japanese, remembered per browser; a blocked
997
+ // storage means the server-rendered default (see LANG_INIT, above) holds. --suggest's pills are the user's own text
998
+ // and stay as given.
999
+ const exRow = document.getElementById('ex'), langBtns = { en: document.getElementById('lang-en'), ja: document.getElementById('lang-ja') };
1000
+ const drawEx = () => {
1001
+ for (const k in langBtns) langBtns[k].setAttribute('aria-pressed', String(k === lang));
1002
+ while (exRow.children.length > 1) exRow.lastChild.remove();
1003
+ let grp = null;
1004
+ for (const e of [...SUGGEST, ...EXAMPLES]) {
1005
+ const ex = lang === 'ja' ? { ...e, h: e.hj ?? e.h, x: e.xj ?? e.x } : e;
1006
+ if (ex.x.includes('S:') && !grp) { grp = document.createElement('span'); grp.className = 'note grp'; grp.lang = lang; grp.textContent = t('multiStep'); exRow.append(grp); }
1007
+ const b = exButton(ex);
1008
+ if (!e.q) b.lang = lang; // a --suggest pill is the user's text, in whatever language they wrote it
1009
+ exRow.append(b);
1010
+ }
1011
+ };
1012
+ // a Japanese page asks the summary in Japanese: an empty summary field gets "in Japanese", and loses it again in English
1013
+ const JA_PROMPT = 'in Japanese';
1014
+ const langPrompt = () => { const el = f.elements['summarize-prompt']; if (lang === 'ja' && !el.value) el.value = JA_PROMPT; else if (lang !== 'ja' && el.value === JA_PROMPT) el.value = ''; show(); };
1015
+ const setLang = k => { lang = k; langFromStorage = true; try { localStorage.setItem('lang', k); } catch {} applyI18n(); drawEx(); if (!ask.hidden) drawAsk(); langPrompt(); };
1016
+ for (const k in langBtns) langBtns[k].onclick = () => setLang(k);
1017
+ const exButton = ex => {
1018
+ const s = ex.x.indexOf('S:');
1019
+ const b = document.createElement('button'); b.type = 'button'; b.textContent = ex.h;
1020
+ b.title = ex.q ? 'suggested at launch (--suggest): ' + (ex.q === 'Q' ? 'a question (-Q)' : 'a meaning (-e)') + '; fills the page, then press Search' : 'an example: fills the page with this search, then press Search';
1021
+ b.onclick = () => {
1022
+ step.checked = s >= 0; fields.textContent = ''; ends.textContent = '';
1023
+ ex.x.forEach((t, i) => i !== s && addField(t[0], t.slice(2), s >= 0 && i > s ? ends : fields));
1024
+ if (s < 0) addField('e', '', ends);
1025
+ setControls({ summarize: f.elements.summarize.checked, 'summarize-prompt': f.elements['summarize-prompt'].value, ...ex }); show(); // an example keeps the summarize choice unless it sets one
1026
+ };
1027
+ return b;
1028
+ };
1029
+ if (lang !== (LANG_INIT ?? 'en')) applyI18n(); // localStorage or navigator.language picked a different language than the server rendered
1030
+ drawEx();
1031
+ document.getElementById('copy').onclick = e => navigator.clipboard.writeText(cmd.textContent).then(() => { e.target.textContent = t('copied'); setTimeout(() => { e.target.textContent = t('copy'); }, 1200); });
1032
+ f.addEventListener('input', show); f.addEventListener('change', show);
1033
+ setControls({ summarize: !LAUNCH.includes('--no-summarize') }); // summarize starts on (owner, 2026-11-03)
1034
+ langPrompt();
1035
+ addField().children[1].focus(); addField('e', '', ends);
1036
+ // the settings panel: read when opened, saved on the button; a key field stays empty (a key never comes back from the server)
1037
+ const sf = document.getElementById('sf'), sd = document.getElementById('sd'), sst = document.getElementById('sst'), gear = document.getElementById('gear');
1038
+ const SFIELDS = ['url', 'model', 'key', 'opts', 'summarizer', 'summarizerModel', 'summarizerKey', 'lang'], SKEYS = ['key', 'summarizerKey'];
1039
+ const SAID = { cmd: () => t('saidCmd'), env: n => n + t('saidEnvSuffix'), settings: () => t('saidSettings'), '.env': () => t('saidDotEnv'), default: () => t('saidDefault') };
1040
+ let langFromStorage = false;
1041
+ try { langFromStorage = localStorage.getItem('lang') === 'en' || localStorage.getItem('lang') === 'ja'; } catch {}
1042
+ const fillSettings = view => {
1043
+ for (const k of SFIELDS) {
1044
+ const [saved, src, name] = view[k], el = sf.elements['set-' + k];
1045
+ if (SKEYS.includes(k)) { el.value = ''; el.placeholder = saved ? 'saved; type to replace' : 'not saved'; sf.elements['clear-' + k].checked = false; }
1046
+ else el.value = k === 'opts' ? (saved ?? []).join('\\n') : saved ?? '';
1047
+ document.getElementById('src-' + k).textContent = SAID[src](name);
1048
+ }
1049
+ document.getElementById('fake-jev').hidden = !view.fake; // a saved url can switch to or from the fake
1050
+ document.getElementById('sum-by').textContent = view.by; // and a saved summarizer changes what summarize runs
1051
+ // the page's own language default just changed (a save, #270): follow it, unless this browser already chose one.
1052
+ if (!langFromStorage && view.pageLang && view.pageLang !== lang) { lang = view.pageLang; applyI18n(); drawEx(); langPrompt(); }
1053
+ };
1054
+ const settingsReq = async (method, body) => {
1055
+ const r = await fetch('/settings', { method, body, headers: { 'x-sys1grep-token': TOKEN, 'content-type': 'application/json' } });
1056
+ const j = await r.json().catch(() => ({ error: r.status + ' ' + r.statusText }));
1057
+ if (!r.ok) throw new Error(j.error);
1058
+ return j;
1059
+ };
1060
+ let bodyOverflow;
1061
+ gear.onclick = () => {
1062
+ sst.textContent = '';
1063
+ settingsReq('GET').then(fillSettings, e => { sst.textContent = e.message; });
1064
+ bodyOverflow = document.body.style.overflow;
1065
+ document.body.style.overflow = 'hidden';
1066
+ sd.showModal();
1067
+ };
1068
+ document.getElementById('sdx').onclick = () => sd.close();
1069
+ sd.onclick = e => { if (e.target === sd) sd.close(); };
1070
+ sd.onclose = () => { document.body.style.overflow = bodyOverflow; gear.focus(); };
1071
+ document.getElementById('save').onclick = () => {
1072
+ const p = {};
1073
+ for (const k of SFIELDS) {
1074
+ const v = sf.elements['set-' + k].value;
1075
+ if (SKEYS.includes(k)) { if (sf.elements['clear-' + k].checked) p[k] = null; else if (v) p[k] = v; }
1076
+ else p[k] = k === 'opts' ? v.split('\\n').map(a => a.trim()).filter(Boolean) : v.trim();
1077
+ }
1078
+ return settingsReq('POST', JSON.stringify(p)).then(view => { fillSettings(view); sst.textContent = t('saved'); }, e => { sst.textContent = t('notSaved') + e.message; });
1079
+ };
1080
+ // hint tooltips: hover (mouse only) and keyboard focus of the "?" are pure CSS; tap toggles .open on the icon's
1081
+ // group (a tap elsewhere or Esc closes every open one), so a touch screen reaches them without a pointer that hovers.
1082
+ // every "?" this page has: the four hand-written ones plus every hg()-built one in Details, so a new hg() call
1083
+ // never needs this list touched by hand.
1084
+ const HINTS = ['step', 'edges', 'rank', 'summarize', 'level', 't', 'T', 'C', 'A', 'B', 'autoscope', 'n', 'p', 'dedup', 'unit', 'o', 'j', 'maxcost', 'include', 'exclude', 'changed', 'g'];
1085
+ const hintEls = HINTS.map(n => ({ n: n, b: document.getElementById('q-' + n), g: document.getElementById('hg-' + n) }));
1086
+ // CSS places each tooltip next to its icon (left-aligned by default, right-aligned for rank/summarize, upward for
1087
+ // multi-step/edges) until this measures it and moves it where it fits. Every layout read happens right-aligned to the icon: a tooltip laid
1088
+ // out across the right edge, even for one measurement, makes a mobile browser widen its viewport (#214 review: the
1089
+ // right column of Details cut off on a tap), and one across the left edge does not. From there a translateX puts it
1090
+ // beside the icon -- left-aligned on the left half of the page, right-aligned on the right half -- and inside the 16px
1091
+ // margin. An upward tooltip that would cross the top opens downward, a downward one past the bottom opens upward,
1092
+ // and downward when neither fits. clientWidth/clientHeight: the layout viewport, without a classic scrollbar.
1093
+ // Runs for every id in HINTS, so a hg()-built hint gets it too.
1094
+ const clampHint = n => {
1095
+ const hint = document.getElementById('hint-' + n), s = hint.style, de = document.documentElement;
1096
+ s.transform = s.top = s.bottom = s.marginTop = s.marginBottom = '';
1097
+ s.left = 'auto'; s.right = '0';
1098
+ if (getComputedStyle(hint).position === 'fixed') { s.left = s.right = ''; return; } // <=480px: the bottom banner
1099
+ const W = de.clientWidth, H = de.clientHeight, ib = hint.parentElement.getBoundingClientRect(), r = hint.getBoundingClientRect();
1100
+ if (!r.width) return; // not shown yet (display: none)
1101
+ const x = Math.max(16, Math.min(ib.left + ib.right > W ? r.left : ib.left, W - 16 - r.width));
1102
+ if (x !== r.left) s.transform = 'translateX(' + (x - r.left) + 'px)';
1103
+ const down = () => { s.top = '100%'; s.bottom = 'auto'; s.marginTop = '6px'; s.marginBottom = '0'; };
1104
+ if (r.bottom <= ib.top) { if (r.top < 0) down(); }
1105
+ else if (r.bottom > H) { s.top = 'auto'; s.bottom = '100%'; s.marginTop = '0'; s.marginBottom = '6px'; if (hint.getBoundingClientRect().top < 0) down(); }
1106
+ };
1107
+ for (const { n, b } of hintEls) {
1108
+ b.parentElement.addEventListener('mouseenter', () => clampHint(n));
1109
+ b.addEventListener('focus', () => clampHint(n));
1110
+ }
1111
+ // blur(): Esc (and a tap elsewhere) must drop real keyboard focus too, or :focus-visible keeps a tap-opened hint's
1112
+ // tooltip visible after .open is gone (r2 review); except: the "?" being toggled keeps focus, so Enter/Space on it
1113
+ // leaves focus (and the screen reader) on the control whose aria-expanded just changed (#214 review).
1114
+ const closeHints = except => { for (const { b, g } of hintEls) { b.setAttribute('aria-expanded', 'false'); g.classList.remove('open'); if (b !== except) b.blur(); } };
1115
+ for (const { n, b, g } of hintEls) b.onclick = e => { e?.stopPropagation?.(); const was = g.classList.contains('open'); closeHints(b); if (!was) { b.setAttribute('aria-expanded', 'true'); g.classList.add('open'); clampHint(n); } };
1116
+ document.addEventListener('click', () => closeHints());
1117
+ // a resize moves the icons; a tooltip left open would keep the placement measured for the old width
1118
+ window.addEventListener('resize', () => closeHints());
1119
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeHints(); });
1120
+ </script></body></html>`;
1121
+ };