lakonai 0.6.1
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/LICENSE +21 -0
- package/README.md +359 -0
- package/assets/logo.svg +12 -0
- package/bin/lakon.js +177 -0
- package/package.json +53 -0
- package/src/filters/cat.js +18 -0
- package/src/filters/git.js +95 -0
- package/src/filters/grep.js +14 -0
- package/src/filters/index.js +35 -0
- package/src/filters/ls.js +34 -0
- package/src/filters/utils.js +29 -0
- package/src/hooks/bash-rewrite.js +54 -0
- package/src/hooks/grep-guard.js +83 -0
- package/src/hooks/read-guard.js +183 -0
- package/src/hooks/session-start.js +33 -0
- package/src/hooks/stop-hook.js +84 -0
- package/src/hooks/throttle.js +32 -0
- package/src/hooks/version-check.js +177 -0
- package/src/install/backup.js +70 -0
- package/src/install/claude-commands.js +71 -0
- package/src/install/claude-hook.js +177 -0
- package/src/install/index.js +159 -0
- package/src/install/paths.js +9 -0
- package/src/install/platforms.js +129 -0
- package/src/rules/caveman.md +103 -0
- package/src/tracking.js +232 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { stripAnsi, truncateLines } = require('./utils');
|
|
4
|
+
|
|
5
|
+
const LOG_COMMIT_RE = /^commit\s+([0-9a-f]{7,40})/i;
|
|
6
|
+
const LOG_AUTHOR_RE = /^Author:\s+/i;
|
|
7
|
+
const LOG_DATE_RE = /^Date:\s+/i;
|
|
8
|
+
const STATUS_HEADER_RE = /^(On branch |Your branch |Untracked files:|Changes |\s*\(use "git )/;
|
|
9
|
+
const DIFF_INDEX_RE = /^(index [0-9a-f]+\.\.[0-9a-f]+|diff --git |---|\+\+\+|@@)/;
|
|
10
|
+
|
|
11
|
+
function filterLog(raw) {
|
|
12
|
+
const text = stripAnsi(raw);
|
|
13
|
+
const lines = text.split('\n');
|
|
14
|
+
const out = [];
|
|
15
|
+
let pendingHash = null;
|
|
16
|
+
let captured = false;
|
|
17
|
+
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
const m = line.match(LOG_COMMIT_RE);
|
|
20
|
+
if (m) {
|
|
21
|
+
pendingHash = m[1].slice(0, 7);
|
|
22
|
+
captured = false;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (LOG_AUTHOR_RE.test(line) || LOG_DATE_RE.test(line) || line.startsWith('Merge:')) continue;
|
|
26
|
+
/* c8 ignore next */
|
|
27
|
+
if (pendingHash && !captured && line.trim() && !line.startsWith(' ')) continue;
|
|
28
|
+
if (pendingHash && !captured && line.trim()) {
|
|
29
|
+
out.push(`${pendingHash} ${line.trim()}`);
|
|
30
|
+
captured = true;
|
|
31
|
+
pendingHash = null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return truncateLines(out.join('\n'), 50);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function filterStatus(raw) {
|
|
38
|
+
const text = stripAnsi(raw);
|
|
39
|
+
const lines = text.split('\n');
|
|
40
|
+
const changed = [];
|
|
41
|
+
const untracked = [];
|
|
42
|
+
let mode = null;
|
|
43
|
+
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
if (line.startsWith('Changes to be committed') || line.startsWith('Changes not staged')) {
|
|
46
|
+
mode = 'changed';
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (line.startsWith('Untracked files:')) {
|
|
50
|
+
mode = 'untracked';
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (STATUS_HEADER_RE.test(line)) continue;
|
|
54
|
+
if (!line.trim()) continue;
|
|
55
|
+
const cleaned = line.replace(/^\s+/, '').replace(/^\(use.*/, '').trim();
|
|
56
|
+
/* c8 ignore next */
|
|
57
|
+
if (!cleaned) continue;
|
|
58
|
+
if (mode === 'changed') changed.push(cleaned);
|
|
59
|
+
else if (mode === 'untracked') untracked.push(cleaned);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const parts = [];
|
|
63
|
+
if (changed.length) parts.push(`changed:\n${changed.join('\n')}`);
|
|
64
|
+
if (untracked.length) parts.push(`untracked:\n${untracked.join('\n')}`);
|
|
65
|
+
return parts.join('\n\n') || 'clean';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function filterDiff(raw) {
|
|
69
|
+
const text = stripAnsi(raw);
|
|
70
|
+
const lines = text.split('\n');
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
if (line.startsWith('diff --git ')) {
|
|
74
|
+
out.push(line);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (DIFF_INDEX_RE.test(line) && !line.startsWith('@@')) continue;
|
|
78
|
+
if (line.startsWith('+') || line.startsWith('-') || line.startsWith('@@')) {
|
|
79
|
+
out.push(line);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return truncateLines(out.join('\n'), 120);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function filter(subcmd, raw) {
|
|
86
|
+
switch (subcmd) {
|
|
87
|
+
case 'log': return filterLog(raw);
|
|
88
|
+
case 'status': return filterStatus(raw);
|
|
89
|
+
case 'diff':
|
|
90
|
+
case 'show': return filterDiff(raw);
|
|
91
|
+
default: return raw;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { filter, filterLog, filterStatus, filterDiff };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { stripAnsi } = require('./utils');
|
|
4
|
+
|
|
5
|
+
const KEEP_HEAD = 15;
|
|
6
|
+
|
|
7
|
+
function filter(raw) {
|
|
8
|
+
const lines = stripAnsi(raw).split('\n').filter(Boolean);
|
|
9
|
+
if (lines.length <= KEEP_HEAD) return lines.join('\n');
|
|
10
|
+
const head = lines.slice(0, KEEP_HEAD).join('\n');
|
|
11
|
+
return `${head}\n… +${lines.length - KEEP_HEAD} more matches (tighten the pattern)`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = { filter };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const git = require('./git');
|
|
4
|
+
const ls = require('./ls');
|
|
5
|
+
const cat = require('./cat');
|
|
6
|
+
const grep = require('./grep');
|
|
7
|
+
const { countTokensApprox } = require('./utils');
|
|
8
|
+
|
|
9
|
+
const HANDLERS = {
|
|
10
|
+
git: (args, raw) => git.filter(args[0], raw),
|
|
11
|
+
ls: (_args, raw) => ls.filter(raw),
|
|
12
|
+
tree: (_args, raw) => ls.filter(raw),
|
|
13
|
+
cat: (_args, raw) => cat.filter(raw),
|
|
14
|
+
head: (_args, raw) => cat.filter(raw, { maxLines: 50 }),
|
|
15
|
+
tail: (_args, raw) => cat.filter(raw, { maxLines: 50 }),
|
|
16
|
+
grep: (_args, raw) => grep.filter(raw),
|
|
17
|
+
rg: (_args, raw) => grep.filter(raw),
|
|
18
|
+
ag: (_args, raw) => grep.filter(raw),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function isSupported(cmd) {
|
|
22
|
+
return Object.prototype.hasOwnProperty.call(HANDLERS, cmd);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function filterCommand(cmd, args, raw) {
|
|
26
|
+
const handler = HANDLERS[cmd];
|
|
27
|
+
if (!handler) return raw;
|
|
28
|
+
try {
|
|
29
|
+
return handler(args, raw);
|
|
30
|
+
} catch {
|
|
31
|
+
return raw;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { filterCommand, isSupported, countTokensApprox, HANDLERS };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { stripAnsi, truncateLines } = require('./utils');
|
|
4
|
+
|
|
5
|
+
const LONG_FORMAT_RE = /^[\-dlcbps][rwx\-]{9}/;
|
|
6
|
+
|
|
7
|
+
function filterLong(raw) {
|
|
8
|
+
const lines = stripAnsi(raw).split('\n');
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const line of lines) {
|
|
11
|
+
if (!line.trim() || line.startsWith('total ')) continue;
|
|
12
|
+
if (LONG_FORMAT_RE.test(line)) {
|
|
13
|
+
const parts = line.split(/\s+/);
|
|
14
|
+
const name = parts.slice(8).join(' ');
|
|
15
|
+
const size = parts[4];
|
|
16
|
+
if (name) out.push(`${size}\t${name}`);
|
|
17
|
+
} else {
|
|
18
|
+
out.push(line);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return truncateLines(out.join('\n'), 60);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function filter(raw) {
|
|
25
|
+
const text = stripAnsi(raw);
|
|
26
|
+
/* c8 ignore next */
|
|
27
|
+
const firstReal = text.split('\n').find((l) => l.trim() && !/^total\s+\d+/.test(l)) || '';
|
|
28
|
+
if (LONG_FORMAT_RE.test(firstReal)) {
|
|
29
|
+
return filterLong(text);
|
|
30
|
+
}
|
|
31
|
+
return truncateLines(text, 60);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { filter, filterLong };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ANSI_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
|
4
|
+
|
|
5
|
+
function stripAnsi(s) {
|
|
6
|
+
return typeof s === 'string' ? s.replace(ANSI_RE, '') : s;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function truncateLines(text, maxLines, marker) {
|
|
10
|
+
const lines = text.split('\n');
|
|
11
|
+
if (lines.length <= maxLines) return text;
|
|
12
|
+
const kept = lines.slice(0, maxLines).join('\n');
|
|
13
|
+
const dropped = lines.length - maxLines;
|
|
14
|
+
const note = marker || `… +${dropped} more lines`;
|
|
15
|
+
return `${kept}\n${note}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function truncateBytes(text, maxBytes, marker) {
|
|
19
|
+
if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text;
|
|
20
|
+
const buf = Buffer.from(text, 'utf8').subarray(0, maxBytes);
|
|
21
|
+
const note = marker || `… truncated at ${maxBytes} bytes`;
|
|
22
|
+
return `${buf.toString('utf8')}\n${note}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function countTokensApprox(text) {
|
|
26
|
+
return text.split(/\s+/).filter(Boolean).length;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { stripAnsi, truncateLines, truncateBytes, countTokensApprox };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const FILTERED_CMDS = new Set([
|
|
5
|
+
'git', 'ls', 'tree', 'cat', 'head', 'tail', 'grep', 'rg', 'ag',
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
function rewriteIfNeeded(command) {
|
|
9
|
+
if (typeof command !== 'string') return null;
|
|
10
|
+
const trimmed = command.trim();
|
|
11
|
+
if (!trimmed) return null;
|
|
12
|
+
if (/^(lakon|lak)(\s|$)/.test(trimmed)) return null;
|
|
13
|
+
const firstWord = trimmed.split(/\s+/)[0];
|
|
14
|
+
if (!FILTERED_CMDS.has(firstWord)) return null;
|
|
15
|
+
return `lakon ${trimmed}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readStdin() {
|
|
19
|
+
let raw = '';
|
|
20
|
+
process.stdin.setEncoding('utf8');
|
|
21
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
22
|
+
return raw;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
try {
|
|
27
|
+
const raw = await readStdin();
|
|
28
|
+
if (!raw.trim()) process.exit(0);
|
|
29
|
+
|
|
30
|
+
const data = JSON.parse(raw);
|
|
31
|
+
if (data.tool_name !== 'Bash') process.exit(0);
|
|
32
|
+
|
|
33
|
+
const command = data.tool_input && data.tool_input.command;
|
|
34
|
+
const rewritten = rewriteIfNeeded(command);
|
|
35
|
+
if (!rewritten) process.exit(0);
|
|
36
|
+
|
|
37
|
+
const response = {
|
|
38
|
+
hookSpecificOutput: {
|
|
39
|
+
hookEventName: 'PreToolUse',
|
|
40
|
+
permissionDecision: 'allow',
|
|
41
|
+
updatedInput: {
|
|
42
|
+
...data.tool_input,
|
|
43
|
+
command: rewritten,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
process.stdout.write(JSON.stringify(response));
|
|
48
|
+
process.exit(0);
|
|
49
|
+
} catch {
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
main();
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { shouldEmit } = require('./throttle');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_HEAD_LIMIT = 30;
|
|
10
|
+
|
|
11
|
+
function lakonHome() {
|
|
12
|
+
/* c8 ignore next */
|
|
13
|
+
return process.env.LAKON_HOME || path.join(os.homedir(), '.lakon');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* c8 ignore start */
|
|
17
|
+
function trackRecord({ cmd, args, rawTokens, filteredTokens }) {
|
|
18
|
+
if (process.env.LAKON_NO_TRACK === '1') return;
|
|
19
|
+
try {
|
|
20
|
+
const dir = lakonHome();
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
const entry = {
|
|
23
|
+
t: Date.now(),
|
|
24
|
+
cmd,
|
|
25
|
+
args: Array.isArray(args) ? args.slice(0, 4) : [],
|
|
26
|
+
raw: rawTokens,
|
|
27
|
+
out: filteredTokens,
|
|
28
|
+
saved: Math.max(0, rawTokens - filteredTokens),
|
|
29
|
+
};
|
|
30
|
+
fs.appendFileSync(path.join(dir, 'log.jsonl'), JSON.stringify(entry) + '\n');
|
|
31
|
+
} catch {
|
|
32
|
+
// never let tracking break the hook
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/* c8 ignore stop */
|
|
36
|
+
|
|
37
|
+
async function readStdin() {
|
|
38
|
+
let raw = '';
|
|
39
|
+
process.stdin.setEncoding('utf8');
|
|
40
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
41
|
+
return raw;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* c8 ignore start */
|
|
45
|
+
async function main() {
|
|
46
|
+
try {
|
|
47
|
+
const raw = await readStdin();
|
|
48
|
+
if (!raw.trim()) process.exit(0);
|
|
49
|
+
const data = JSON.parse(raw);
|
|
50
|
+
if (data.tool_name !== 'Grep') process.exit(0);
|
|
51
|
+
|
|
52
|
+
const input = data.tool_input || {};
|
|
53
|
+
if (input.head_limit != null) process.exit(0);
|
|
54
|
+
|
|
55
|
+
const updatedInput = { ...input, head_limit: DEFAULT_HEAD_LIMIT };
|
|
56
|
+
const reason = shouldEmit('grep-head-cap')
|
|
57
|
+
? `lakon: head_limit auto-set to ${DEFAULT_HEAD_LIMIT}. Pass head_limit explicitly to override; pass output_mode:"count" for a tally instead of matches.`
|
|
58
|
+
: undefined;
|
|
59
|
+
|
|
60
|
+
trackRecord({
|
|
61
|
+
cmd: 'Grep',
|
|
62
|
+
args: [input.pattern || '', 'cap'],
|
|
63
|
+
rawTokens: 200,
|
|
64
|
+
filteredTokens: 50,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const response = {
|
|
68
|
+
hookSpecificOutput: {
|
|
69
|
+
hookEventName: 'PreToolUse',
|
|
70
|
+
permissionDecision: 'allow',
|
|
71
|
+
updatedInput,
|
|
72
|
+
...(reason ? { permissionDecisionReason: reason } : {}),
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
process.stdout.write(JSON.stringify(response));
|
|
76
|
+
process.exit(0);
|
|
77
|
+
} catch {
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/* c8 ignore stop */
|
|
82
|
+
|
|
83
|
+
main();
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
|
|
8
|
+
function lakonHome() {
|
|
9
|
+
/* c8 ignore next */
|
|
10
|
+
return process.env.LAKON_HOME || path.join(os.homedir(), '.lakon');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* c8 ignore start */
|
|
14
|
+
function trackRecord({ cmd, args, rawTokens, filteredTokens }) {
|
|
15
|
+
if (process.env.LAKON_NO_TRACK === '1') return;
|
|
16
|
+
try {
|
|
17
|
+
const dir = lakonHome();
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
const entry = {
|
|
20
|
+
t: Date.now(),
|
|
21
|
+
cmd,
|
|
22
|
+
args: Array.isArray(args) ? args.slice(0, 4) : [],
|
|
23
|
+
raw: rawTokens,
|
|
24
|
+
out: filteredTokens,
|
|
25
|
+
saved: Math.max(0, rawTokens - filteredTokens),
|
|
26
|
+
};
|
|
27
|
+
fs.appendFileSync(path.join(dir, 'log.jsonl'), JSON.stringify(entry) + '\n');
|
|
28
|
+
} catch {
|
|
29
|
+
// never let tracking break the hook
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/* c8 ignore stop */
|
|
33
|
+
|
|
34
|
+
const DENY_DIRS = [
|
|
35
|
+
'node_modules',
|
|
36
|
+
'.next',
|
|
37
|
+
'.nuxt',
|
|
38
|
+
'dist',
|
|
39
|
+
'build',
|
|
40
|
+
'target',
|
|
41
|
+
'.turbo',
|
|
42
|
+
'.cache',
|
|
43
|
+
'coverage',
|
|
44
|
+
'__pycache__',
|
|
45
|
+
'.venv',
|
|
46
|
+
'venv',
|
|
47
|
+
'vendor',
|
|
48
|
+
'.git/objects',
|
|
49
|
+
'__snapshots__',
|
|
50
|
+
'.ipynb_checkpoints',
|
|
51
|
+
'.mypy_cache',
|
|
52
|
+
'.pytest_cache',
|
|
53
|
+
'.ruff_cache',
|
|
54
|
+
'.tox',
|
|
55
|
+
'.svelte-kit',
|
|
56
|
+
'.parcel-cache',
|
|
57
|
+
'.vercel',
|
|
58
|
+
'tmp',
|
|
59
|
+
'cypress/screenshots',
|
|
60
|
+
'cypress/videos',
|
|
61
|
+
'playwright-report',
|
|
62
|
+
'test-results',
|
|
63
|
+
'.idea',
|
|
64
|
+
'.vscode',
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const DENY_FILE_RE = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lock(b)?|Cargo\.lock|Gemfile\.lock|composer\.lock|poetry\.lock|uv\.lock|go\.sum|.*\.tsbuildinfo|.*\.log|.*\.min\.(js|css|mjs)|.*\.map|.*\.pyc|.*\.pyo|.*\.so|.*\.o|.*\.a|.*\.dylib|.*\.dll|.*\.exe|.*\.class|.*\.wasm)$/;
|
|
68
|
+
|
|
69
|
+
const AUTO_CAP_LINES = 800;
|
|
70
|
+
|
|
71
|
+
function isDeniedPath(p) {
|
|
72
|
+
/* c8 ignore next */
|
|
73
|
+
if (typeof p !== 'string' || !p) return null;
|
|
74
|
+
const norm = p.replace(/\\/g, '/');
|
|
75
|
+
for (const dir of DENY_DIRS) {
|
|
76
|
+
if (norm.includes(`/${dir}/`) || norm.endsWith(`/${dir}`) || norm.startsWith(`${dir}/`)) {
|
|
77
|
+
return `path lives under ${dir}/ — read costs context for noise. grep -n the symbol instead, then Read with offset/limit.`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (DENY_FILE_RE.test(norm)) {
|
|
81
|
+
return 'lockfile/build artifact — almost never useful for the agent. grep -n the symbol inside if you must.';
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function fileLineCount(p) {
|
|
87
|
+
try {
|
|
88
|
+
const data = fs.readFileSync(p, 'utf8');
|
|
89
|
+
let n = 0;
|
|
90
|
+
for (let i = 0; i < data.length; i++) if (data.charCodeAt(i) === 10) n++;
|
|
91
|
+
if (data.length && data.charCodeAt(data.length - 1) !== 10) n++;
|
|
92
|
+
return n;
|
|
93
|
+
/* c8 ignore next 3 */
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function estimateTokensByBytes(p) {
|
|
100
|
+
try {
|
|
101
|
+
const size = fs.statSync(p).size;
|
|
102
|
+
return Math.max(1, Math.round(size / 4));
|
|
103
|
+
} catch {
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readStdin() {
|
|
109
|
+
let raw = '';
|
|
110
|
+
process.stdin.setEncoding('utf8');
|
|
111
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
112
|
+
return raw;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* c8 ignore start */
|
|
116
|
+
async function main() {
|
|
117
|
+
try {
|
|
118
|
+
const raw = await readStdin();
|
|
119
|
+
if (!raw.trim()) process.exit(0);
|
|
120
|
+
const data = JSON.parse(raw);
|
|
121
|
+
if (data.tool_name !== 'Read') process.exit(0);
|
|
122
|
+
|
|
123
|
+
const input = data.tool_input || {};
|
|
124
|
+
const fp = input.file_path;
|
|
125
|
+
if (typeof fp !== 'string' || !fp) process.exit(0);
|
|
126
|
+
|
|
127
|
+
const denyReason = isDeniedPath(fp);
|
|
128
|
+
if (denyReason) {
|
|
129
|
+
const rawTokens = estimateTokensByBytes(fp);
|
|
130
|
+
trackRecord({
|
|
131
|
+
cmd: 'Read',
|
|
132
|
+
args: [fp, 'deny'],
|
|
133
|
+
rawTokens,
|
|
134
|
+
filteredTokens: 0,
|
|
135
|
+
});
|
|
136
|
+
const response = {
|
|
137
|
+
hookSpecificOutput: {
|
|
138
|
+
hookEventName: 'PreToolUse',
|
|
139
|
+
permissionDecision: 'deny',
|
|
140
|
+
permissionDecisionReason: `lakon: ${denyReason}`,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
process.stdout.write(JSON.stringify(response));
|
|
144
|
+
process.exit(0);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (input.limit == null && input.offset == null) {
|
|
148
|
+
const n = fileLineCount(fp);
|
|
149
|
+
if (n !== null && n > AUTO_CAP_LINES) {
|
|
150
|
+
const rawTokens = estimateTokensByBytes(fp);
|
|
151
|
+
const capRatio = AUTO_CAP_LINES / n;
|
|
152
|
+
const filteredTokens = Math.round(rawTokens * capRatio);
|
|
153
|
+
trackRecord({
|
|
154
|
+
cmd: 'Read',
|
|
155
|
+
args: [fp, 'cap'],
|
|
156
|
+
rawTokens,
|
|
157
|
+
filteredTokens,
|
|
158
|
+
});
|
|
159
|
+
const response = {
|
|
160
|
+
hookSpecificOutput: {
|
|
161
|
+
hookEventName: 'PreToolUse',
|
|
162
|
+
permissionDecision: 'allow',
|
|
163
|
+
updatedInput: {
|
|
164
|
+
...input,
|
|
165
|
+
offset: 1,
|
|
166
|
+
limit: AUTO_CAP_LINES,
|
|
167
|
+
},
|
|
168
|
+
permissionDecisionReason: `lakon: file has ${n} lines, capped at ${AUTO_CAP_LINES}. Read again with offset=${AUTO_CAP_LINES + 1} for more, or grep -n the symbol you need.`,
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
process.stdout.write(JSON.stringify(response));
|
|
172
|
+
process.exit(0);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
process.exit(0);
|
|
177
|
+
} catch {
|
|
178
|
+
process.exit(0);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/* c8 ignore stop */
|
|
182
|
+
|
|
183
|
+
main();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { checkForUpdate, formatNotice } = require('./version-check');
|
|
5
|
+
|
|
6
|
+
async function readStdin() {
|
|
7
|
+
let raw = '';
|
|
8
|
+
process.stdin.setEncoding('utf8');
|
|
9
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
10
|
+
return raw;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function main() {
|
|
14
|
+
try {
|
|
15
|
+
await readStdin();
|
|
16
|
+
const update = await checkForUpdate();
|
|
17
|
+
if (!update) process.exit(0);
|
|
18
|
+
|
|
19
|
+
const response = {
|
|
20
|
+
hookSpecificOutput: {
|
|
21
|
+
hookEventName: 'SessionStart',
|
|
22
|
+
additionalContext: formatNotice(update),
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
process.stdout.write(JSON.stringify(response));
|
|
26
|
+
process.exit(0);
|
|
27
|
+
/* c8 ignore next 3 */
|
|
28
|
+
} catch {
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
main();
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
|
|
8
|
+
function lakonHome() {
|
|
9
|
+
/* c8 ignore next */
|
|
10
|
+
return process.env.LAKON_HOME || path.join(os.homedir(), '.lakon');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* c8 ignore start */
|
|
14
|
+
function trackSession(payload) {
|
|
15
|
+
if (process.env.LAKON_NO_TRACK === '1') return;
|
|
16
|
+
try {
|
|
17
|
+
const dir = lakonHome();
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
const entry = { t: Date.now(), cmd: 'session', ...payload };
|
|
20
|
+
fs.appendFileSync(path.join(dir, 'log.jsonl'), JSON.stringify(entry) + '\n');
|
|
21
|
+
} catch {
|
|
22
|
+
// never let tracking break the hook
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/* c8 ignore stop */
|
|
26
|
+
|
|
27
|
+
async function readStdin() {
|
|
28
|
+
let raw = '';
|
|
29
|
+
process.stdin.setEncoding('utf8');
|
|
30
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
31
|
+
return raw;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function extractUsage(transcriptPath) {
|
|
35
|
+
try {
|
|
36
|
+
const content = fs.readFileSync(transcriptPath, 'utf8');
|
|
37
|
+
const lines = content.split('\n').filter(Boolean);
|
|
38
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
39
|
+
try {
|
|
40
|
+
const obj = JSON.parse(lines[i]);
|
|
41
|
+
const msg = obj.message;
|
|
42
|
+
if (msg && msg.role === 'assistant' && msg.usage) {
|
|
43
|
+
return {
|
|
44
|
+
/* c8 ignore next 2 */
|
|
45
|
+
in_tokens: msg.usage.input_tokens || 0,
|
|
46
|
+
out_tokens: msg.usage.output_tokens || 0,
|
|
47
|
+
cache_read: msg.usage.cache_read_input_tokens || 0,
|
|
48
|
+
cache_create: msg.usage.cache_creation_input_tokens || 0,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// skip malformed lines
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/* c8 ignore next 3 */
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* c8 ignore start */
|
|
63
|
+
async function main() {
|
|
64
|
+
try {
|
|
65
|
+
const raw = await readStdin();
|
|
66
|
+
if (!raw.trim()) process.exit(0);
|
|
67
|
+
const data = JSON.parse(raw);
|
|
68
|
+
if (!data.transcript_path) process.exit(0);
|
|
69
|
+
|
|
70
|
+
const usage = extractUsage(data.transcript_path);
|
|
71
|
+
if (!usage) process.exit(0);
|
|
72
|
+
|
|
73
|
+
trackSession({
|
|
74
|
+
session_id: data.session_id || null,
|
|
75
|
+
...usage,
|
|
76
|
+
});
|
|
77
|
+
process.exit(0);
|
|
78
|
+
} catch {
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/* c8 ignore stop */
|
|
83
|
+
|
|
84
|
+
main();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
/* c8 ignore next */
|
|
8
|
+
const MARKER_DIR = path.join(os.tmpdir(), `lakon-${process.env.USER || 'session'}`);
|
|
9
|
+
const TTL_MS = 4 * 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
/* c8 ignore start */
|
|
12
|
+
function shouldEmit(category) {
|
|
13
|
+
if (process.env.LAKON_NO_THROTTLE === '1') return true;
|
|
14
|
+
try {
|
|
15
|
+
fs.mkdirSync(MARKER_DIR, { recursive: true });
|
|
16
|
+
const marker = path.join(MARKER_DIR, `${category}.marker`);
|
|
17
|
+
try {
|
|
18
|
+
const st = fs.statSync(marker);
|
|
19
|
+
if (Date.now() - st.mtimeMs < TTL_MS) return false;
|
|
20
|
+
} catch {
|
|
21
|
+
/* not yet emitted */
|
|
22
|
+
}
|
|
23
|
+
const fd = fs.openSync(marker, fs.constants.O_CREAT | fs.constants.O_WRONLY | fs.constants.O_TRUNC);
|
|
24
|
+
fs.closeSync(fd);
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/* c8 ignore stop */
|
|
31
|
+
|
|
32
|
+
module.exports = { shouldEmit };
|