@litfamily/litgrok 1.0.16 → 1.0.17
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/.grok/hooks/auto-handoff-gate.mjs +3 -17
- package/.grok/hooks/auto-handoff.mjs +66 -7
- package/.grok/hooks/deliverable-hedge-guard.mjs +7 -6
- package/.grok/hooks/lit-status-line.mjs +294 -30
- package/.grok/hooks/plan-gate.mjs +2 -10
- package/.grok/hooks/record-passive-event.mjs +174 -28
- package/.grok/hooks/session-start.mjs +8 -5
- package/.grok/rules/00-litgrok.md +1 -1
- package/.grok/skills/lit-typographic-motion/SKILL.md +2 -2
- package/.grok/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +2 -2
- package/.grok/skills/lit-typographic-motion/fonts/pins.json +1 -1
- package/.grok/skills/lit-typographic-motion/package.json +1 -1
- package/.grok/skills/lit-typographic-motion/references/complete-contract.md +1 -1
- package/.grok/skills/lit-typographic-motion/references/runtime.md +12 -8
- package/.grok/skills/lit-typographic-motion/references/scene-contract.md +1 -1
- package/.grok/skills/lit-typographic-motion/requirements-audio.txt +1 -1
- package/.grok/skills/lit-typographic-motion/scripts/lib/cache.mjs +21 -3
- package/.grok/skills/lit-typographic-motion/scripts/motion.mjs +1 -1
- package/.grok/skills/lit-typographic-motion/scripts/prewarm.mjs +3 -3
- package/.grok/skills/litgrok/SKILL.md +2 -2
- package/.grok-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +15 -13
- package/README_ko-KR.md +15 -13
- package/bin/litgrok.mjs +76 -2
- package/bin/status-line-config.mjs +79 -9
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference.md +15 -5
- package/docs/reference_ko-KR.md +17 -7
- package/package.json +19 -2
- package/plugin.json +1 -1
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
// what makes it fire once.
|
|
9
9
|
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
|
-
import {
|
|
11
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
12
12
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { resolveAutoHandoff } from './auto-handoff.mjs';
|
|
15
15
|
import { readContextRecord } from './litgrok-hud-state.mjs';
|
|
16
|
+
import { readLedgerRecords } from './record-passive-event.mjs';
|
|
16
17
|
|
|
17
18
|
export const DIRECTIVE_SIGNAL = 'auto-handoff-directive';
|
|
18
19
|
export const RELOAD_SIGNAL = 'auto-handoff-reload';
|
|
@@ -40,21 +41,6 @@ function carriesMarker(text, marker) {
|
|
|
40
41
|
return text.split(/\r?\n/).some((line) => lineCarriesMarker(line, marker));
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
function ledgerRecords(ledgerFile, sessionId) {
|
|
44
|
-
if (!existsSync(ledgerFile)) return [];
|
|
45
|
-
const records = [];
|
|
46
|
-
for (const line of readFileSync(ledgerFile, 'utf8').split(/\r?\n/)) {
|
|
47
|
-
if (!line) continue;
|
|
48
|
-
try {
|
|
49
|
-
const record = JSON.parse(line);
|
|
50
|
-
if (record.sessionId === sessionId) records.push(record);
|
|
51
|
-
} catch {
|
|
52
|
-
// A damaged line is skipped; the recorder itself refuses to append to a ledger it cannot read.
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return records;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
44
|
function lastIndex(records, predicate, after = -1) {
|
|
59
45
|
for (let index = records.length - 1; index > after; index -= 1) {
|
|
60
46
|
if (predicate(records[index])) return index;
|
|
@@ -121,7 +107,7 @@ export function evaluateAutoHandoff({ event, env = process.env, workspaceRoot, e
|
|
|
121
107
|
const settings = resolveAutoHandoff({ env, dirs: [workspaceRoot, eventCwd] });
|
|
122
108
|
if (!settings.active) return { block: false };
|
|
123
109
|
|
|
124
|
-
const records =
|
|
110
|
+
const records = readLedgerRecords(ledgerFile, sessionId);
|
|
125
111
|
const directiveIndex = lastIndex(records, (record) => record.signal === DIRECTIVE_SIGNAL);
|
|
126
112
|
const compactIndex = directiveIndex < 0 ? -1 : lastIndex(records, (record) => record.event === 'PostCompact', directiveIndex);
|
|
127
113
|
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// environment); `on` without a number reuses the last value, and there is no built-in default.
|
|
5
5
|
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
8
|
-
import { isAbsolute, join } from 'node:path';
|
|
7
|
+
import { lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
9
9
|
import { readNewestContextRecord } from './litgrok-hud-state.mjs';
|
|
10
|
+
import { ignoreStateDirectory } from './record-passive-event.mjs';
|
|
10
11
|
|
|
11
12
|
export const ENABLE_VARIABLE = 'LITGROK_AUTO_HANDOFF';
|
|
12
13
|
export const PERCENT_VARIABLE = 'LITGROK_AUTO_HANDOFF_PERCENT';
|
|
@@ -23,6 +24,58 @@ function configPath(directory) {
|
|
|
23
24
|
return join(directory, ...CONFIG_FILE.split('/'));
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
function present(path) {
|
|
28
|
+
try {
|
|
29
|
+
lstatSync(path);
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function samePath(left, right) {
|
|
37
|
+
const canonical = (path) => {
|
|
38
|
+
try {
|
|
39
|
+
return realpathSync(path);
|
|
40
|
+
} catch {
|
|
41
|
+
return resolve(path);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
return canonical(left) === canonical(right);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Project hooks run only from a Git project root, and Grok hands its workspace root to the Stop hook and
|
|
48
|
+
// the status line. The command picks the nearest Git root from any subfolder; outside Git it uses cwd.
|
|
49
|
+
export function projectRoot(cwd) {
|
|
50
|
+
let directory = resolve(cwd);
|
|
51
|
+
for (;;) {
|
|
52
|
+
if (present(join(directory, '.git'))) return { root: directory, git: true };
|
|
53
|
+
const parent = dirname(directory);
|
|
54
|
+
if (parent === directory) return { root: resolve(cwd), git: false };
|
|
55
|
+
directory = parent;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The host decides the workspace root, so say when the chosen folder may not be the one it uses.
|
|
60
|
+
function rootNotes({ root, git }, home) {
|
|
61
|
+
const notes = [];
|
|
62
|
+
if (!git) {
|
|
63
|
+
notes.push(`Note: no Git root above ${root}, so this setting belongs to that folder alone. Project hooks need a Git root, and they read this file only when Grok Build starts in that folder.`);
|
|
64
|
+
} else if (home && samePath(root, home)) {
|
|
65
|
+
notes.push(`Note: the Git root here is your home folder (${root}), so this setting applies to Grok Build sessions started in your home folder. For a project inside it, run git init in the project and run this command there.`);
|
|
66
|
+
}
|
|
67
|
+
for (let directory = dirname(root); ; directory = dirname(directory)) {
|
|
68
|
+
// A dotfiles repository at home is not a project root unless LitGrok already keeps state there.
|
|
69
|
+
const homeRepository = home && samePath(directory, home) && !present(join(directory, '.grok', 'litgrok'));
|
|
70
|
+
if (!homeRepository && (present(join(directory, '.git')) || present(join(directory, '.grok', 'litgrok')))) {
|
|
71
|
+
notes.push(`Note: ${directory} above it is also a project root. The hooks read the file under the folder where you start Grok Build: ${configPath(root)} when you start in ${root}, or ${configPath(directory)} when you start in ${directory}. Run this command in the folder where you start Grok Build.`);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
if (dirname(directory) === directory) break;
|
|
75
|
+
}
|
|
76
|
+
return notes;
|
|
77
|
+
}
|
|
78
|
+
|
|
26
79
|
// Returns { found: false } or { found: true, valid, enabled, percent, reason }.
|
|
27
80
|
export function readAutoHandoffFile(directory) {
|
|
28
81
|
const path = configPath(directory);
|
|
@@ -127,6 +180,7 @@ function ensureDirectory(path) {
|
|
|
127
180
|
export function writeAutoHandoffFile(directory, settings) {
|
|
128
181
|
ensureDirectory(join(directory, '.grok'));
|
|
129
182
|
ensureDirectory(join(directory, '.grok', 'litgrok'));
|
|
183
|
+
ignoreStateDirectory(join(directory, '.grok', 'litgrok'));
|
|
130
184
|
const target = configPath(directory);
|
|
131
185
|
try {
|
|
132
186
|
const existing = lstatSync(target);
|
|
@@ -189,13 +243,17 @@ export async function runAutoHandoff(args, { env = process.env, stdout = process
|
|
|
189
243
|
return 1;
|
|
190
244
|
}
|
|
191
245
|
|
|
192
|
-
const
|
|
246
|
+
const located = projectRoot(cwd);
|
|
247
|
+
const { root } = located;
|
|
248
|
+
const notes = rootNotes(located, env.HOME || null);
|
|
249
|
+
const file = `Project file: ${configPath(root)}`;
|
|
250
|
+
const resolved = resolveAutoHandoff({ env, dirs: [root] });
|
|
193
251
|
if (action === 'status') {
|
|
194
|
-
stdout.write(`${describe(resolved, env).join('\n')}\n`);
|
|
252
|
+
stdout.write(`${[...describe(resolved, env), file, ...notes].join('\n')}\n`);
|
|
195
253
|
return 0;
|
|
196
254
|
}
|
|
197
255
|
|
|
198
|
-
const stored = readAutoHandoffFile(
|
|
256
|
+
const stored = readAutoHandoffFile(root);
|
|
199
257
|
const previous = stored.found && stored.valid ? stored.percent : null;
|
|
200
258
|
let next;
|
|
201
259
|
if (action === 'off') {
|
|
@@ -211,12 +269,12 @@ export async function runAutoHandoff(args, { env = process.env, stdout = process
|
|
|
211
269
|
}
|
|
212
270
|
|
|
213
271
|
try {
|
|
214
|
-
writeAutoHandoffFile(
|
|
272
|
+
writeAutoHandoffFile(root, next);
|
|
215
273
|
} catch (error) {
|
|
216
274
|
return fail(`litgrok auto-handoff: ${error instanceof Error ? error.message : String(error)}`);
|
|
217
275
|
}
|
|
218
276
|
|
|
219
|
-
const after = resolveAutoHandoff({ env, dirs: [
|
|
277
|
+
const after = resolveAutoHandoff({ env, dirs: [root] });
|
|
220
278
|
const lines = [];
|
|
221
279
|
if (action === 'off') {
|
|
222
280
|
lines.push('Automatic handoff is OFF for this project.');
|
|
@@ -225,6 +283,7 @@ export async function runAutoHandoff(args, { env = process.env, stdout = process
|
|
|
225
283
|
lines.push(`Automatic handoff is ON at ${next.percent} percent for this project.`);
|
|
226
284
|
lines.push('When a turn ends with the context at or above that percent, the model is asked to write a handoff and then to tell you to run /compact.');
|
|
227
285
|
}
|
|
286
|
+
lines.push(`Saved in ${configPath(root)}`, ...notes);
|
|
228
287
|
if (after.enabled !== next.enabled) lines.push(`Warning: ${ENABLE_VARIABLE} is set in this shell, so sessions started from here stay ${after.enabled ? 'ON' : 'OFF'}.`);
|
|
229
288
|
const { warning } = hostWarning(next.enabled ? next.percent : null, env);
|
|
230
289
|
if (warning) lines.push(warning);
|
|
@@ -97,14 +97,15 @@ function loadRules() {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
async function readEvent() {
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
// Decode once: a multi-byte character can straddle two pipe chunks.
|
|
101
|
+
const chunks = [];
|
|
102
|
+
let bytes = 0;
|
|
102
103
|
for await (const chunk of process.stdin) {
|
|
103
|
-
|
|
104
|
-
if (
|
|
104
|
+
bytes += chunk.length;
|
|
105
|
+
if (bytes <= MAX_EVENT_BYTES) chunks.push(chunk);
|
|
105
106
|
}
|
|
106
|
-
if (
|
|
107
|
-
try { return JSON.parse(
|
|
107
|
+
if (bytes > MAX_EVENT_BYTES) throw new Error('event exceeded the size limit; write left unchanged');
|
|
108
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new Error('event could not be parsed; write left unchanged'); }
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
try {
|
|
@@ -1,20 +1,50 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync, readSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
4
7
|
import { resolveAutoHandoff } from './auto-handoff.mjs';
|
|
5
8
|
import { readHudRecordForCwd, writeContextRecord } from './litgrok-hud-state.mjs';
|
|
6
9
|
|
|
7
10
|
const MAX_INPUT_BYTES = 64 * 1024;
|
|
8
11
|
const GRADIENT_STOPS = [[255, 99, 55], [255, 45, 149], [0, 229, 255]];
|
|
12
|
+
const BRAND_COLOR = [255, 99, 55];
|
|
13
|
+
// Grok cuts each line at 1024 characters, escapes included.
|
|
14
|
+
const MAX_LINE_CHARACTERS = 1024;
|
|
15
|
+
const RECORD_SCHEMA = 'litgrok.hook-event/v1';
|
|
16
|
+
// The ribbon reads only the end of the session ledger, enough for a few hundred records.
|
|
17
|
+
const LEDGER_TAIL_BYTES = 128 * 1024;
|
|
18
|
+
const COMPLETE_SHOWN_MS = 20 * 1000;
|
|
19
|
+
const HANDOFF_NEAR_POINTS = 10;
|
|
20
|
+
const CLOCK_SKEW_MS = 5 * 1000;
|
|
21
|
+
const TOOL_EVENTS = new Set(['PostToolUse', 'PostToolUseFailure']);
|
|
22
|
+
// [background, foreground] of each pill.
|
|
23
|
+
const PILLS = {
|
|
24
|
+
brand: [[255, 45, 149], [18, 6, 12]],
|
|
25
|
+
running: [[10, 166, 194], [0, 19, 24]],
|
|
26
|
+
complete: [[29, 77, 54], [184, 245, 207]],
|
|
27
|
+
warning: [[90, 58, 8], [255, 210, 138]],
|
|
28
|
+
};
|
|
29
|
+
const TEXT = { dim: [139, 148, 158], amber: [255, 175, 60], green: [107, 219, 143] };
|
|
30
|
+
const GLYPHS = {
|
|
31
|
+
basic: { running: '●', complete: '✓', warning: '⚠', timer: '', tools: (n) => `${n} tool${n === 1 ? '' : 's'}`, agents: (n) => `${n} agent${n === 1 ? '' : 's'}` },
|
|
32
|
+
nerd: { running: '\uf111', complete: '\uf00c', warning: '\uf071', timer: '\uf017 ', tools: (n) => `\uf0ad ${n}`, agents: (n) => `\uf0c0 ${n}` },
|
|
33
|
+
};
|
|
34
|
+
const ANSI_16 = [[0, 0, 0], [205, 0, 0], [0, 205, 0], [205, 205, 0], [0, 0, 238], [205, 0, 205], [0, 205, 205], [229, 229, 229],
|
|
35
|
+
[127, 127, 127], [255, 0, 0], [0, 255, 0], [255, 255, 0], [92, 92, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255]];
|
|
9
36
|
|
|
10
37
|
async function readInput() {
|
|
11
|
-
|
|
38
|
+
// Decode once: a multi-byte character can straddle two pipe chunks.
|
|
39
|
+
const chunks = [];
|
|
40
|
+
let bytes = 0;
|
|
12
41
|
for await (const chunk of process.stdin) {
|
|
13
|
-
|
|
14
|
-
if (
|
|
42
|
+
bytes += chunk.length;
|
|
43
|
+
if (bytes > MAX_INPUT_BYTES) return null;
|
|
44
|
+
chunks.push(chunk);
|
|
15
45
|
}
|
|
16
46
|
try {
|
|
17
|
-
const value = JSON.parse(
|
|
47
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
18
48
|
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
19
49
|
} catch {
|
|
20
50
|
return null;
|
|
@@ -31,9 +61,42 @@ function plainText(value, fallback) {
|
|
|
31
61
|
return cleaned || fallback;
|
|
32
62
|
}
|
|
33
63
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
64
|
+
// The installer writes its manifest beside the hooks folder, so an installed copy can name its version.
|
|
65
|
+
function installedVersion() {
|
|
66
|
+
try {
|
|
67
|
+
const manifest = JSON.parse(readFileSync(join(dirname(dirname(fileURLToPath(import.meta.url))), '.litgrok-install-manifest.json'), 'utf8'));
|
|
68
|
+
return typeof manifest?.version === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(manifest.version) ? manifest.version : '';
|
|
69
|
+
} catch {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Grok sends effort.level only for models that take a reasoning effort.
|
|
75
|
+
function effortLevel(value) {
|
|
76
|
+
return typeof value === 'string' && /^[a-z][a-z-]{0,11}$/u.test(value) ? value : '';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function windowSize(value) {
|
|
80
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return '';
|
|
81
|
+
if (value >= 1000000 && value % 100000 === 0) return `${value / 1000000}m`;
|
|
82
|
+
return `${Math.max(1, Math.round(value / 1000))}k`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function contextBar(percent) {
|
|
86
|
+
const exact = (percent * 3) / 100;
|
|
87
|
+
const filled = Math.floor(exact);
|
|
88
|
+
const partialBlocks = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
|
|
89
|
+
const partial = filled < 3 && exact > filled ? Math.max(1, Math.min(7, Math.round((exact - filled) * 8))) : 0;
|
|
90
|
+
return `${'█'.repeat(filled)}${partialBlocks[partial]}${'░'.repeat(3 - filled - (partial > 0 ? 1 : 0))}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A percent Grok did not send shows as ?, never as a guess.
|
|
94
|
+
function contextText(contextWindow) {
|
|
95
|
+
const size = windowSize(contextWindow?.context_window_size);
|
|
96
|
+
const value = contextWindow?.used_percentage;
|
|
97
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return size ? `ctx ?/${size}` : 'ctx ?';
|
|
98
|
+
const percent = Math.round(Math.max(0, Math.min(100, value)));
|
|
99
|
+
return `ctx [${contextBar(percent)}] ${percent}%${size ? `/${size}` : ''}`;
|
|
37
100
|
}
|
|
38
101
|
|
|
39
102
|
function cellWidth(character) {
|
|
@@ -65,37 +128,62 @@ function clipCells(text, maximum) {
|
|
|
65
128
|
return result;
|
|
66
129
|
}
|
|
67
130
|
|
|
131
|
+
function distance(left, right) {
|
|
132
|
+
return left.reduce((total, value, index) => total + (value - right[index]) ** 2, 0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function xterm256(rgb) {
|
|
136
|
+
const levels = [0, 95, 135, 175, 215, 255];
|
|
137
|
+
const nearest = (value) => levels.reduce((best, level, index) => (Math.abs(level - value) < Math.abs(levels[best] - value) ? index : best), 0);
|
|
138
|
+
const cube = rgb.map(nearest);
|
|
139
|
+
const gray = Math.max(0, Math.min(23, Math.round((rgb.reduce((total, value) => total + value, 0) / 3 - 8) / 10)));
|
|
140
|
+
const grayValue = 8 + gray * 10;
|
|
141
|
+
return distance(rgb, [grayValue, grayValue, grayValue]) < distance(rgb, cube.map((index) => levels[index]))
|
|
142
|
+
? 232 + gray
|
|
143
|
+
: 16 + cube[0] * 36 + cube[1] * 6 + cube[2];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// depth is 24 (truecolor), 256 or 16; the 16-colour codes are the nearest of the standard xterm palette.
|
|
147
|
+
function sgr(rgb, depth, background = false) {
|
|
148
|
+
if (depth === 256) return `\u001b[${background ? 48 : 38};5;${xterm256(rgb)}m`;
|
|
149
|
+
if (depth === 16) {
|
|
150
|
+
const index = ANSI_16.reduce((best, color, at) => (distance(rgb, color) < distance(rgb, ANSI_16[best]) ? at : best), 0);
|
|
151
|
+
return `\u001b[${(index < 8 ? 30 : 82) + index + (background ? 10 : 0)}m`;
|
|
152
|
+
}
|
|
153
|
+
return `\u001b[${background ? 48 : 38};2;${rgb.join(';')}m`;
|
|
154
|
+
}
|
|
155
|
+
|
|
68
156
|
function gradientColor(position) {
|
|
69
157
|
const stop = position <= 0.5 ? 0 : 1;
|
|
70
158
|
const fraction = position <= 0.5 ? position * 2 : (position - 0.5) * 2;
|
|
71
159
|
return GRADIENT_STOPS[stop].map((value, index) => Math.round(value + (GRADIENT_STOPS[stop + 1][index] - value) * fraction));
|
|
72
160
|
}
|
|
73
161
|
|
|
74
|
-
|
|
162
|
+
// Bold is set once and each letter only changes the colour, so the longest label stays far below the cut.
|
|
163
|
+
function paintLabel(text, depth) {
|
|
75
164
|
const characters = [...text];
|
|
76
|
-
return characters.map((character, index) => {
|
|
165
|
+
return `\u001b[1m${characters.map((character, index) => {
|
|
77
166
|
if (character === ' ') return character;
|
|
78
167
|
const position = characters.length === 1 ? 0 : index / (characters.length - 1);
|
|
79
|
-
return
|
|
80
|
-
}).join('')
|
|
168
|
+
return `${sgr(gradientColor(position), depth)}${character}`;
|
|
169
|
+
}).join('')}\u001b[0m`;
|
|
81
170
|
}
|
|
82
171
|
|
|
83
|
-
function formatRow(discipline, model, context,
|
|
172
|
+
function formatRow({ discipline, model, effort, context, version }, depth, maxCells) {
|
|
173
|
+
const brand = `[🔥LIT${version ? ` v${version}` : ''}]`;
|
|
174
|
+
const suffix = `${effort ? ` [${effort}]` : ''} │ ${context}`;
|
|
175
|
+
let ignition = '';
|
|
176
|
+
let ignitionText = '';
|
|
84
177
|
if (discipline) {
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
const label = `LIT IGNITED · ${
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const modelText = clipCells(model, Math.max(0, 79 - width(prefix + middle + suffix)));
|
|
92
|
-
return `🔥 ${color ? paintLabel(label) : label} 🔥 │ ${modelText}${suffix}`;
|
|
178
|
+
// The skill name gives way after the model, and the model keeps at least its ellipsis.
|
|
179
|
+
const room = maxCells - width(`${brand} 🔥 LIT IGNITED · 🔥 | ${suffix}`) - 1;
|
|
180
|
+
const maxDisciplineCells = Math.max(1, Math.min(discipline === 'lit-scientific-visualization' ? 32 : 24, room));
|
|
181
|
+
const label = `LIT IGNITED · ${clipCells(discipline, maxDisciplineCells)}`;
|
|
182
|
+
ignitionText = ` 🔥 ${label} 🔥`;
|
|
183
|
+
ignition = ` 🔥 ${depth ? paintLabel(label, depth) : label} 🔥`;
|
|
93
184
|
}
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
const modelText = clipCells(model, Math.max(0, 79 - width(prefix + suffix)));
|
|
97
|
-
const row = `${prefix}${modelText}${suffix}`;
|
|
98
|
-
return color ? `\u001b[1m\u001b[38;2;255;99;55m${row}\u001b[0m` : row;
|
|
185
|
+
const modelText = clipCells(model, Math.max(1, maxCells - width(`${brand}${ignitionText} | ${suffix}`)));
|
|
186
|
+
return `${depth ? `\u001b[1m${sgr(BRAND_COLOR, depth)}${brand}\u001b[0m` : brand}${ignition} | ${modelText}${suffix}`;
|
|
99
187
|
}
|
|
100
188
|
|
|
101
189
|
// Automatic handoff needs Grok's context percent, which only the status line receives. Nothing is
|
|
@@ -116,6 +204,162 @@ function recordContextForHandoff(status) {
|
|
|
116
204
|
});
|
|
117
205
|
}
|
|
118
206
|
|
|
207
|
+
function statusDirs(status) {
|
|
208
|
+
return [status?.workspace?.repo_root, status?.workspace?.current_dir, status?.cwd];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// The passive hooks keep the session ledger under the workspace root Grok gives them, which the status
|
|
212
|
+
// input names as one of these folders. Only a regular file is read, and only its end.
|
|
213
|
+
function readLedgerTail(status) {
|
|
214
|
+
const sessionId = status?.session_id;
|
|
215
|
+
if (typeof sessionId !== 'string' || sessionId === '' || sessionId.length > 512) return null;
|
|
216
|
+
const name = `${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}.jsonl`;
|
|
217
|
+
for (const directory of statusDirs(status)) {
|
|
218
|
+
if (typeof directory !== 'string' || !isAbsolute(directory)) continue;
|
|
219
|
+
let descriptor;
|
|
220
|
+
try {
|
|
221
|
+
descriptor = openSync(join(realpathSync(directory), '.grok', 'litgrok', 'session-ledger', name), constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
222
|
+
} catch {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
const file = fstatSync(descriptor);
|
|
227
|
+
if (!file.isFile()) return null;
|
|
228
|
+
const length = Math.min(file.size, LEDGER_TAIL_BYTES);
|
|
229
|
+
const buffer = Buffer.alloc(length);
|
|
230
|
+
let text = buffer.subarray(0, readSync(descriptor, buffer, 0, length, file.size - length)).toString('utf8');
|
|
231
|
+
// A read that starts mid-file drops its first, partial line.
|
|
232
|
+
if (length < file.size) text = text.slice(text.indexOf('\n') + 1 || text.length);
|
|
233
|
+
return text.split('\n').flatMap((line) => {
|
|
234
|
+
try {
|
|
235
|
+
const record = JSON.parse(line);
|
|
236
|
+
return record?.schema === RECORD_SCHEMA && record.sessionId === sessionId && Number.isFinite(Date.parse(record.recordedAt)) ? [record] : [];
|
|
237
|
+
} catch {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
} finally {
|
|
244
|
+
closeSync(descriptor);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// What the ledger says about the current turn: it starts at the prompt Grok names, or the latest one.
|
|
251
|
+
// A turn whose start is not in the tail is unknown, so nothing is counted.
|
|
252
|
+
function turnActivity(records, promptId) {
|
|
253
|
+
if (!records) return null;
|
|
254
|
+
const prompts = records.flatMap((record, index) => (record.event === 'UserPromptSubmit' ? [index] : []));
|
|
255
|
+
const matching = typeof promptId === 'string' ? prompts.filter((index) => records[index].promptId === promptId) : [];
|
|
256
|
+
const start = (matching.length > 0 ? matching : prompts).at(-1);
|
|
257
|
+
if (start === undefined) return null;
|
|
258
|
+
const turn = records.slice(start + 1);
|
|
259
|
+
const count = (event) => turn.filter((record) => record.event === event).length;
|
|
260
|
+
const held = (record) => record.signal === 'plan-gate-block' || record.signal === 'auto-handoff-directive'
|
|
261
|
+
|| (record.signal === 'auto-handoff-reload' && record.outcome === 'loaded');
|
|
262
|
+
const last = turn.at(-1);
|
|
263
|
+
const ended = last?.event === 'Stop' && !held(last);
|
|
264
|
+
return {
|
|
265
|
+
startedAt: Date.parse(records[start].recordedAt),
|
|
266
|
+
endedAt: ended ? Date.parse(last.recordedAt) : null,
|
|
267
|
+
tools: turn.filter((record) => TOOL_EVENTS.has(record.event)).length,
|
|
268
|
+
agents: Math.max(0, count('SubagentStart') - count('SubagentStop')),
|
|
269
|
+
planGate: !ended && turn.some((record) => record.signal === 'plan-gate-block'),
|
|
270
|
+
handoffAsked: !ended && turn.some((record) => record.signal === 'auto-handoff-directive'),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function duration(ms) {
|
|
275
|
+
const seconds = Math.floor(ms / 1000);
|
|
276
|
+
if (seconds < 60) return `${seconds}s`;
|
|
277
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`;
|
|
278
|
+
return `${Math.floor(seconds / 3600)}h${String(Math.floor(seconds / 60) % 60).padStart(2, '0')}m`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Returns [long, short] warning text, or null. A plan-gate hold comes first, then a handoff request,
|
|
282
|
+
// then a context at or near the automatic-handoff percent.
|
|
283
|
+
function warningText(status, activity) {
|
|
284
|
+
if (activity?.planGate) return ['plan gate: save plans/<slug>.md', 'plan gate'];
|
|
285
|
+
if (activity?.handoffAsked) return ['handoff requested', 'handoff'];
|
|
286
|
+
const used = status?.context_window?.used_percentage;
|
|
287
|
+
if (typeof used !== 'number' || !Number.isFinite(used)) return null;
|
|
288
|
+
const settings = resolveAutoHandoff({ env: process.env, dirs: statusDirs(status) });
|
|
289
|
+
if (!settings.active) return null;
|
|
290
|
+
const percent = Math.round(Math.max(0, Math.min(100, used)));
|
|
291
|
+
if (percent >= settings.percent) return [`ctx ${percent}% ≥ handoff at ${settings.percent}%`, `ctx ${percent}%≥${settings.percent}%`];
|
|
292
|
+
if (percent >= settings.percent - HANDOFF_NEAR_POINTS) return [`ctx ${percent}% → handoff at ${settings.percent}%`, `ctx ${percent}%→${settings.percent}%`];
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function bandState(status, now) {
|
|
297
|
+
const started = status?.turn?.started_at_ms;
|
|
298
|
+
const running = typeof started === 'number' && Number.isFinite(started) && started > 0;
|
|
299
|
+
const activity = turnActivity(readLedgerTail(status), running ? status.prompt_id : undefined);
|
|
300
|
+
const warning = warningText(status, activity);
|
|
301
|
+
let timer = null;
|
|
302
|
+
let state = null;
|
|
303
|
+
if (running) {
|
|
304
|
+
state = 'running';
|
|
305
|
+
if (started <= now + CLOCK_SKEW_MS) timer = duration(Math.max(0, now - started));
|
|
306
|
+
} else if (activity?.endedAt && now - activity.endedAt <= COMPLETE_SHOWN_MS && activity.endedAt <= now + CLOCK_SKEW_MS) {
|
|
307
|
+
state = 'complete';
|
|
308
|
+
timer = duration(Math.max(0, activity.endedAt - activity.startedAt));
|
|
309
|
+
}
|
|
310
|
+
if (state === null && warning === null) return null;
|
|
311
|
+
return { state, timer, warning, activity: running || state === 'complete' ? activity : null };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function pill(text, kind, depth, nerd) {
|
|
315
|
+
if (!depth) return { plain: `(${text})`, painted: `(${text})` };
|
|
316
|
+
const [background, foreground] = PILLS[kind];
|
|
317
|
+
if (nerd) {
|
|
318
|
+
const cap = (glyph) => `${sgr(background, depth)}${glyph}\u001b[0m`;
|
|
319
|
+
return { plain: `${text}`, painted: `${cap('')}${sgr(background, depth, true)}${sgr(foreground, depth)}\u001b[1m${text}\u001b[0m${cap('')}` };
|
|
320
|
+
}
|
|
321
|
+
return { plain: ` ${text} `, painted: `${sgr(background, depth, true)}${sgr(foreground, depth)}\u001b[1m ${text} \u001b[0m` };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function renderBand(discipline, band, depth, nerd, { warningIndex = 0, tools = true, agents = true, timer = true, warning = true, brand = 'full', skillCells = Infinity }) {
|
|
325
|
+
const glyphs = nerd ? GLYPHS.nerd : GLYPHS.basic;
|
|
326
|
+
const kind = band.warning ? 'warning' : band.state === 'complete' ? 'complete' : 'running';
|
|
327
|
+
const pills = [pill(brand === 'full' ? '🔥 LIT' : '🔥', 'brand', depth, nerd), pill(`${glyphs[kind]} ${clipCells(discipline, skillCells)}`, kind, depth, nerd)];
|
|
328
|
+
const items = [];
|
|
329
|
+
const paint = (text, rgb, bold = false) => (depth ? `${bold ? '\u001b[1m' : ''}${sgr(rgb, depth)}${text}\u001b[0m` : text);
|
|
330
|
+
if (band.warning && warning) items.push({ plain: band.warning[warningIndex], painted: paint(band.warning[warningIndex], TEXT.amber, true) });
|
|
331
|
+
if (band.state === 'complete') items.push({ plain: 'complete', painted: paint('complete', TEXT.green, true) });
|
|
332
|
+
if (band.timer && timer) items.push({ plain: `${glyphs.timer}${band.timer}`, painted: paint(`${glyphs.timer}${band.timer}`, TEXT.dim) });
|
|
333
|
+
if (band.activity && tools) items.push({ plain: glyphs.tools(band.activity.tools), painted: paint(glyphs.tools(band.activity.tools), TEXT.dim) });
|
|
334
|
+
if (band.activity && agents && band.state === 'running' && band.activity.agents > 0) {
|
|
335
|
+
items.push({ plain: glyphs.agents(band.activity.agents), painted: paint(glyphs.agents(band.activity.agents), TEXT.dim) });
|
|
336
|
+
}
|
|
337
|
+
const separator = { plain: ' · ', painted: paint(' · ', TEXT.dim) };
|
|
338
|
+
const join = (key) => [pills.map((part) => part[key]).join(' '), items.map((part) => part[key]).join(separator[key])].filter(Boolean).join(' ');
|
|
339
|
+
return { plain: join('plain'), painted: join('painted') };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Narrow rows give up tool calls first, then subagents, the timer, the long warning, the warning itself
|
|
343
|
+
// and the brand's name, and last of all clip the skill name.
|
|
344
|
+
function formatBand(discipline, band, depth, nerd, maxCells) {
|
|
345
|
+
const tiers = [
|
|
346
|
+
{},
|
|
347
|
+
{ tools: false },
|
|
348
|
+
{ tools: false, agents: false },
|
|
349
|
+
{ tools: false, agents: false, timer: false },
|
|
350
|
+
{ tools: false, agents: false, timer: false, warningIndex: 1 },
|
|
351
|
+
{ tools: false, agents: false, timer: false, warningIndex: 1, brand: 'compact' },
|
|
352
|
+
{ tools: false, agents: false, timer: false, warning: false, brand: 'compact' },
|
|
353
|
+
];
|
|
354
|
+
for (const tier of tiers) {
|
|
355
|
+
const row = renderBand(discipline, band, depth, nerd, tier);
|
|
356
|
+
if (width(row.plain) <= maxCells && row.painted.length <= MAX_LINE_CHARACTERS) return row.painted;
|
|
357
|
+
}
|
|
358
|
+
const last = tiers.at(-1);
|
|
359
|
+
const room = maxCells - width(renderBand('', band, depth, nerd, { ...last, skillCells: 0 }).plain);
|
|
360
|
+
return renderBand(discipline, band, depth, nerd, { ...last, skillCells: Math.max(1, room) }).painted;
|
|
361
|
+
}
|
|
362
|
+
|
|
119
363
|
const input = await readInput();
|
|
120
364
|
try {
|
|
121
365
|
recordContextForHandoff(input);
|
|
@@ -131,7 +375,27 @@ try {
|
|
|
131
375
|
}
|
|
132
376
|
const discipline = record?.discipline ?? null;
|
|
133
377
|
const model = plainText(input?.model?.display_name, 'grok');
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
process.
|
|
378
|
+
const colorSetting = process.env.LITGROK_HUD_COLOR;
|
|
379
|
+
const depth = colorSetting === '0' || Object.hasOwn(process.env, 'NO_COLOR') ? 0 : colorSetting === '256' ? 256 : colorSetting === '16' ? 16 : 24;
|
|
380
|
+
// Grok sets COLUMNS to the width of the row; without it the row keeps to 79 cells.
|
|
381
|
+
const columns = Number.parseInt(process.env.COLUMNS ?? '', 10);
|
|
382
|
+
const maxCells = Number.isFinite(columns) && columns >= 20 ? Math.min(columns, 1000) - 1 : 79;
|
|
383
|
+
const row = formatRow({
|
|
384
|
+
discipline,
|
|
385
|
+
model,
|
|
386
|
+
effort: effortLevel(input?.effort?.level),
|
|
387
|
+
context: contextText(input?.context_window),
|
|
388
|
+
version: installedVersion(),
|
|
389
|
+
}, depth, maxCells);
|
|
390
|
+
// The ribbon is a second line, shown only while a skill is active and a turn runs, has just ended or
|
|
391
|
+
// needs attention. LITGROK_HUD_LINES=1 keeps the compact row alone.
|
|
392
|
+
let band = null;
|
|
393
|
+
if (discipline && process.env.LITGROK_HUD_LINES !== '1') {
|
|
394
|
+
try {
|
|
395
|
+
const state = bandState(input, Date.now());
|
|
396
|
+
if (state) band = formatBand(discipline, state, depth, process.env.LITGROK_HUD_GLYPHS === 'nerd', maxCells);
|
|
397
|
+
} catch {
|
|
398
|
+
band = null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
process.stdout.write(band ? `${row}\n${band}\n` : `${row}\n`);
|
|
@@ -11,21 +11,13 @@ import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
|
|
13
13
|
import { countImplementationRows } from '../skills/lit-plan/scripts/scaffold-plan.mjs';
|
|
14
|
+
import { readLedgerRecords } from './record-passive-event.mjs';
|
|
14
15
|
|
|
15
16
|
export const PLAN_GATE_SIGNAL = 'plan-gate-block';
|
|
16
17
|
export const PLAN_GATE_MAX_BLOCKS = 2;
|
|
17
18
|
const SCAFFOLD = '.grok/skills/lit-plan/scripts/scaffold-plan.mjs';
|
|
18
19
|
const MAX_PLAN_BYTES = 256 * 1024;
|
|
19
20
|
|
|
20
|
-
function ledgerRecords(ledgerFile, sessionId) {
|
|
21
|
-
if (!existsSync(ledgerFile)) return [];
|
|
22
|
-
return readFileSync(ledgerFile, 'utf8')
|
|
23
|
-
.split(/\r?\n/)
|
|
24
|
-
.filter(Boolean)
|
|
25
|
-
.map((line) => JSON.parse(line))
|
|
26
|
-
.filter((record) => record.sessionId === sessionId);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
21
|
function turnStart(records, promptId) {
|
|
30
22
|
const prompts = records.filter((record) => record.event === 'UserPromptSubmit');
|
|
31
23
|
const matching = typeof promptId === 'string' ? prompts.filter((record) => record.promptId === promptId) : [];
|
|
@@ -59,7 +51,7 @@ export function evaluatePlanGate({ event, workspaceRoot, sessionId, ledgerFile }
|
|
|
59
51
|
if (event.reason !== undefined && event.reason !== 'end_turn') return { block: false };
|
|
60
52
|
if (event.subagentType !== undefined) return { block: false };
|
|
61
53
|
|
|
62
|
-
const records =
|
|
54
|
+
const records = readLedgerRecords(ledgerFile, sessionId);
|
|
63
55
|
const { since, litPlanTurn } = turnStart(records, event.promptId);
|
|
64
56
|
const planMode = event.permissionMode === 'plan' || litPlanTurn;
|
|
65
57
|
const written = plansWrittenSince(workspaceRoot, since);
|