@v1nvn/agentic-core 0.14.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/dist/index.js +179 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
- package/src/hook.ts +61 -0
- package/src/index.ts +3 -0
- package/src/last-reply.ts +91 -0
- package/src/text-format.ts +83 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
//#region src/hook.ts
|
|
5
|
+
/**
|
|
6
|
+
* Read the Claude Code hook event JSON from stdin; empty or unparseable input
|
|
7
|
+
* yields an empty event, never a thrown error — a hook must always answer.
|
|
8
|
+
*/
|
|
9
|
+
function readHookEvent(stream = process.stdin) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
let data = "";
|
|
12
|
+
stream.setEncoding("utf8");
|
|
13
|
+
stream.on("data", (chunk) => {
|
|
14
|
+
data += chunk;
|
|
15
|
+
});
|
|
16
|
+
stream.on("end", () => {
|
|
17
|
+
try {
|
|
18
|
+
resolve(JSON.parse(data));
|
|
19
|
+
} catch {
|
|
20
|
+
resolve({});
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
stream.on("error", () => {
|
|
24
|
+
resolve({});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Which transcript lastReply should read, per the hook event: transcript_path
|
|
30
|
+
* when it names a real file, else session_id, else nothing (lastReply then
|
|
31
|
+
* falls back to the newest session for the current project).
|
|
32
|
+
*/
|
|
33
|
+
function replyTarget(event) {
|
|
34
|
+
if (event.transcript_path !== void 0 && isFile$1(event.transcript_path)) return event.transcript_path;
|
|
35
|
+
return event.session_id || void 0;
|
|
36
|
+
}
|
|
37
|
+
function isFile$1(path) {
|
|
38
|
+
try {
|
|
39
|
+
return statSync(path).isFile();
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Print the UserPromptExpansion decision for a zero-token command: `block`
|
|
46
|
+
* keeps the command from reaching the model, with the output as the `reason`.
|
|
47
|
+
*/
|
|
48
|
+
function emitHookBlock(reason) {
|
|
49
|
+
process.stdout.write(`${JSON.stringify({
|
|
50
|
+
decision: "block",
|
|
51
|
+
reason
|
|
52
|
+
})}\n`);
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/last-reply.ts
|
|
56
|
+
function isFile(path) {
|
|
57
|
+
try {
|
|
58
|
+
return statSync(path).isFile();
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function textBlocks(entry) {
|
|
64
|
+
const content = entry.message?.content;
|
|
65
|
+
if (entry.type !== "assistant" || !Array.isArray(content)) return;
|
|
66
|
+
const blocks = content.filter((b) => typeof b === "object" && b !== null && b.type === "text");
|
|
67
|
+
return blocks.length > 0 ? blocks : void 0;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The last assistant text reply from a Claude Code session, reproducing Claude
|
|
71
|
+
* Code's `/copy` byte-for-byte: the last assistant entry that contains a
|
|
72
|
+
* `text` block, only its `text` block(s) (tool_use / thinking dropped), blocks
|
|
73
|
+
* joined with a blank line, and no trailing newline.
|
|
74
|
+
*
|
|
75
|
+
* @param arg a transcript file, a session UUID under the project dir, or
|
|
76
|
+
* nothing to use the newest session for the current project.
|
|
77
|
+
*/
|
|
78
|
+
function lastReply(arg) {
|
|
79
|
+
const claudeDir = process.env.CLAUDE_DIR ?? join(homedir(), ".claude");
|
|
80
|
+
const proj = (process.env.CLAUDE_PROJECT_DIR ?? process.cwd()).replaceAll("/", "-");
|
|
81
|
+
const projDir = join(claudeDir, "projects", proj);
|
|
82
|
+
let file;
|
|
83
|
+
if (arg !== void 0) file = isFile(arg) ? arg : join(projDir, `${arg}.jsonl`);
|
|
84
|
+
else if (existsSync(projDir)) {
|
|
85
|
+
const newest = readdirSync(projDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({
|
|
86
|
+
f,
|
|
87
|
+
mtimeMs: statSync(join(projDir, f)).mtimeMs
|
|
88
|
+
})).sort((a, b) => b.mtimeMs - a.mtimeMs).at(0);
|
|
89
|
+
if (newest) file = join(projDir, newest.f);
|
|
90
|
+
}
|
|
91
|
+
if (file === void 0 || !isFile(file)) throw new Error(`no session transcript found in ${projDir}`);
|
|
92
|
+
let last;
|
|
93
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
94
|
+
if (!line.includes("\"assistant\"")) continue;
|
|
95
|
+
let entry;
|
|
96
|
+
try {
|
|
97
|
+
entry = JSON.parse(line);
|
|
98
|
+
} catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const blocks = textBlocks(entry);
|
|
102
|
+
if (blocks) last = blocks;
|
|
103
|
+
}
|
|
104
|
+
return (last ?? []).map((b) => b.text ?? "").join("\n\n");
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/text-format.ts
|
|
108
|
+
/**
|
|
109
|
+
* Plain-text rendering primitives shared by the zai and tokens report
|
|
110
|
+
* renderers. Output targets a monospace terminal / hook-block `reason`, so
|
|
111
|
+
* everything here is fixed-width: padding, block-glyph bars, and compact
|
|
112
|
+
* number formatting.
|
|
113
|
+
*/
|
|
114
|
+
var MONTHS = [
|
|
115
|
+
"Jan",
|
|
116
|
+
"Feb",
|
|
117
|
+
"Mar",
|
|
118
|
+
"Apr",
|
|
119
|
+
"May",
|
|
120
|
+
"Jun",
|
|
121
|
+
"Jul",
|
|
122
|
+
"Aug",
|
|
123
|
+
"Sep",
|
|
124
|
+
"Oct",
|
|
125
|
+
"Nov",
|
|
126
|
+
"Dec"
|
|
127
|
+
];
|
|
128
|
+
var EIGHTHS = [
|
|
129
|
+
"",
|
|
130
|
+
"▏",
|
|
131
|
+
"▎",
|
|
132
|
+
"▍",
|
|
133
|
+
"▌",
|
|
134
|
+
"▋",
|
|
135
|
+
"▊",
|
|
136
|
+
"▉"
|
|
137
|
+
];
|
|
138
|
+
function fmtTokens(n) {
|
|
139
|
+
if (n == null || Number.isNaN(n)) return "—";
|
|
140
|
+
if (n >= 1e9) return (n / 1e9).toFixed(1) + "B";
|
|
141
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
|
142
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
|
|
143
|
+
return String(n);
|
|
144
|
+
}
|
|
145
|
+
function fmtNum(n) {
|
|
146
|
+
return (n || 0).toLocaleString("en-US");
|
|
147
|
+
}
|
|
148
|
+
function padR(s, n) {
|
|
149
|
+
return s.length >= n ? s : s + " ".repeat(n - s.length);
|
|
150
|
+
}
|
|
151
|
+
function padL(s, n) {
|
|
152
|
+
return s.length >= n ? s : " ".repeat(n - s.length) + s;
|
|
153
|
+
}
|
|
154
|
+
/** Fixed-width bar field (width cols): █ blocks + an eighth-fraction + trailing spaces. */
|
|
155
|
+
function barField(v, max, width) {
|
|
156
|
+
if (!v || v <= 0 || max <= 0) return " ".repeat(width);
|
|
157
|
+
const scaled = v / max * width;
|
|
158
|
+
let full = Math.floor(scaled);
|
|
159
|
+
let fi = Math.round((scaled - full) * 8);
|
|
160
|
+
if (fi === 8) {
|
|
161
|
+
full += 1;
|
|
162
|
+
fi = 0;
|
|
163
|
+
}
|
|
164
|
+
if (full === 0 && fi === 0) fi = 1;
|
|
165
|
+
let s = "█".repeat(Math.min(full, width));
|
|
166
|
+
if (full < width && fi > 0) s += EIGHTHS[fi];
|
|
167
|
+
if (s.length < width) s += " ".repeat(width - s.length);
|
|
168
|
+
return s.slice(0, width);
|
|
169
|
+
}
|
|
170
|
+
/** Filled/empty meter: █ for used, ░ for remaining. */
|
|
171
|
+
function meter(pct, width) {
|
|
172
|
+
let filled = Math.round((pct || 0) / 100 * width);
|
|
173
|
+
filled = Math.max(0, Math.min(width, filled));
|
|
174
|
+
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
175
|
+
}
|
|
176
|
+
//#endregion
|
|
177
|
+
export { MONTHS, barField, emitHookBlock, fmtNum, fmtTokens, lastReply, meter, padL, padR, readHookEvent, replyTarget };
|
|
178
|
+
|
|
179
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/hook.ts","../src/last-reply.ts","../src/text-format.ts"],"sourcesContent":["import { statSync } from 'node:fs';\n\nexport interface HookEvent {\n cwd?: string;\n session_id?: string;\n transcript_path?: string;\n}\n\n/**\n * Read the Claude Code hook event JSON from stdin; empty or unparseable input\n * yields an empty event, never a thrown error — a hook must always answer.\n */\nexport function readHookEvent(\n stream: NodeJS.ReadableStream = process.stdin,\n): Promise<HookEvent> {\n return new Promise(resolve => {\n let data = '';\n stream.setEncoding('utf8');\n stream.on('data', (chunk: string) => {\n data += chunk;\n });\n stream.on('end', () => {\n try {\n resolve(JSON.parse(data) as HookEvent);\n } catch {\n resolve({});\n }\n });\n stream.on('error', () => {\n resolve({});\n });\n });\n}\n\n/**\n * Which transcript lastReply should read, per the hook event: transcript_path\n * when it names a real file, else session_id, else nothing (lastReply then\n * falls back to the newest session for the current project).\n */\nexport function replyTarget(event: HookEvent): string | undefined {\n if (event.transcript_path !== undefined && isFile(event.transcript_path)) {\n return event.transcript_path;\n }\n return event.session_id || undefined;\n}\n\nfunction isFile(path: string): boolean {\n try {\n return statSync(path).isFile();\n } catch {\n return false;\n }\n}\n\n/**\n * Print the UserPromptExpansion decision for a zero-token command: `block`\n * keeps the command from reaching the model, with the output as the `reason`.\n */\nexport function emitHookBlock(reason: string): void {\n process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\\n`);\n}\n","import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\ninterface ContentBlock {\n text?: string;\n type: string;\n}\n\ninterface TranscriptEntry {\n message?: { content?: unknown };\n type?: string;\n}\n\nfunction isFile(path: string): boolean {\n try {\n return statSync(path).isFile();\n } catch {\n return false;\n }\n}\n\nfunction textBlocks(entry: TranscriptEntry): ContentBlock[] | undefined {\n const content = entry.message?.content;\n if (entry.type !== 'assistant' || !Array.isArray(content)) {\n return undefined;\n }\n const blocks = content.filter(\n (b): b is ContentBlock =>\n typeof b === 'object' &&\n b !== null &&\n (b as ContentBlock).type === 'text',\n );\n return blocks.length > 0 ? blocks : undefined;\n}\n\n/**\n * The last assistant text reply from a Claude Code session, reproducing Claude\n * Code's `/copy` byte-for-byte: the last assistant entry that contains a\n * `text` block, only its `text` block(s) (tool_use / thinking dropped), blocks\n * joined with a blank line, and no trailing newline.\n *\n * @param arg a transcript file, a session UUID under the project dir, or\n * nothing to use the newest session for the current project.\n */\nexport function lastReply(arg?: string): string {\n const claudeDir = process.env.CLAUDE_DIR ?? join(homedir(), '.claude');\n // Claude Code keys transcripts under the project root with \"/\" → \"-\".\n // Prefer $CLAUDE_PROJECT_DIR (exported to hook processes) over $PWD.\n const proj = (process.env.CLAUDE_PROJECT_DIR ?? process.cwd()).replaceAll(\n '/',\n '-',\n );\n const projDir = join(claudeDir, 'projects', proj);\n\n let file: string | undefined;\n if (arg !== undefined) {\n file = isFile(arg) ? arg : join(projDir, `${arg}.jsonl`);\n } else if (existsSync(projDir)) {\n const newest = readdirSync(projDir)\n .filter(f => f.endsWith('.jsonl'))\n .map(f => ({ f, mtimeMs: statSync(join(projDir, f)).mtimeMs }))\n .sort((a, b) => b.mtimeMs - a.mtimeMs)\n .at(0);\n if (newest) {\n file = join(projDir, newest.f);\n }\n }\n\n if (file === undefined || !isFile(file)) {\n throw new Error(`no session transcript found in ${projDir}`);\n }\n\n let last: ContentBlock[] | undefined;\n for (const line of readFileSync(file, 'utf8').split('\\n')) {\n if (!line.includes('\"assistant\"')) {\n continue;\n }\n let entry: TranscriptEntry;\n try {\n entry = JSON.parse(line) as TranscriptEntry;\n } catch {\n continue;\n }\n const blocks = textBlocks(entry);\n if (blocks) {\n last = blocks;\n }\n }\n return (last ?? []).map(b => b.text ?? '').join('\\n\\n');\n}\n","/**\n * Plain-text rendering primitives shared by the zai and tokens report\n * renderers. Output targets a monospace terminal / hook-block `reason`, so\n * everything here is fixed-width: padding, block-glyph bars, and compact\n * number formatting.\n */\n\nexport const MONTHS = [\n 'Jan',\n 'Feb',\n 'Mar',\n 'Apr',\n 'May',\n 'Jun',\n 'Jul',\n 'Aug',\n 'Sep',\n 'Oct',\n 'Nov',\n 'Dec',\n];\n\nconst EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];\n\nexport function fmtTokens(n: null | number | undefined): string {\n if (n == null || Number.isNaN(n)) {\n return '—';\n }\n if (n >= 1e9) {\n return (n / 1e9).toFixed(1) + 'B';\n }\n if (n >= 1e6) {\n return (n / 1e6).toFixed(1) + 'M';\n }\n if (n >= 1e3) {\n return (n / 1e3).toFixed(1) + 'K';\n }\n return String(n);\n}\n\nexport function fmtNum(n: null | number | undefined): string {\n return (n || 0).toLocaleString('en-US');\n}\n\nexport function padR(s: string, n: number): string {\n return s.length >= n ? s : s + ' '.repeat(n - s.length);\n}\n\nexport function padL(s: string, n: number): string {\n return s.length >= n ? s : ' '.repeat(n - s.length) + s;\n}\n\n/** Fixed-width bar field (width cols): █ blocks + an eighth-fraction + trailing spaces. */\nexport function barField(v: number, max: number, width: number): string {\n if (!v || v <= 0 || max <= 0) {\n return ' '.repeat(width);\n }\n const scaled = (v / max) * width;\n let full = Math.floor(scaled);\n let fi = Math.round((scaled - full) * 8);\n if (fi === 8) {\n full += 1;\n fi = 0;\n }\n if (full === 0 && fi === 0) {\n fi = 1;\n } // keep a sliver for any nonzero value\n let s = '█'.repeat(Math.min(full, width));\n if (full < width && fi > 0) {\n s += EIGHTHS[fi];\n }\n if (s.length < width) {\n s += ' '.repeat(width - s.length);\n }\n return s.slice(0, width);\n}\n\n/** Filled/empty meter: █ for used, ░ for remaining. */\nexport function meter(pct: number | undefined, width: number): string {\n let filled = Math.round(((pct || 0) / 100) * width);\n filled = Math.max(0, Math.min(width, filled));\n return '█'.repeat(filled) + '░'.repeat(width - filled);\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@v1nvn/agentic-core",
|
|
3
|
+
"version": "0.14.0",
|
|
4
|
+
"description": "Shared runtime for the @v1nvn agent tools — last-reply transcript reader and fixed-width text formatting.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "vite build",
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"test:watch": "vitest"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"claude-code",
|
|
20
|
+
"transcripts",
|
|
21
|
+
"cli"
|
|
22
|
+
],
|
|
23
|
+
"author": "v1nvn",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/v1nvn/agentic.git",
|
|
28
|
+
"directory": "packages/core"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src"
|
|
33
|
+
],
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"vite": "^8.1.4",
|
|
36
|
+
"vitest": "^4.1.10"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=22"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/hook.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
export interface HookEvent {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
session_id?: string;
|
|
6
|
+
transcript_path?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read the Claude Code hook event JSON from stdin; empty or unparseable input
|
|
11
|
+
* yields an empty event, never a thrown error — a hook must always answer.
|
|
12
|
+
*/
|
|
13
|
+
export function readHookEvent(
|
|
14
|
+
stream: NodeJS.ReadableStream = process.stdin,
|
|
15
|
+
): Promise<HookEvent> {
|
|
16
|
+
return new Promise(resolve => {
|
|
17
|
+
let data = '';
|
|
18
|
+
stream.setEncoding('utf8');
|
|
19
|
+
stream.on('data', (chunk: string) => {
|
|
20
|
+
data += chunk;
|
|
21
|
+
});
|
|
22
|
+
stream.on('end', () => {
|
|
23
|
+
try {
|
|
24
|
+
resolve(JSON.parse(data) as HookEvent);
|
|
25
|
+
} catch {
|
|
26
|
+
resolve({});
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
stream.on('error', () => {
|
|
30
|
+
resolve({});
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Which transcript lastReply should read, per the hook event: transcript_path
|
|
37
|
+
* when it names a real file, else session_id, else nothing (lastReply then
|
|
38
|
+
* falls back to the newest session for the current project).
|
|
39
|
+
*/
|
|
40
|
+
export function replyTarget(event: HookEvent): string | undefined {
|
|
41
|
+
if (event.transcript_path !== undefined && isFile(event.transcript_path)) {
|
|
42
|
+
return event.transcript_path;
|
|
43
|
+
}
|
|
44
|
+
return event.session_id || undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isFile(path: string): boolean {
|
|
48
|
+
try {
|
|
49
|
+
return statSync(path).isFile();
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Print the UserPromptExpansion decision for a zero-token command: `block`
|
|
57
|
+
* keeps the command from reaching the model, with the output as the `reason`.
|
|
58
|
+
*/
|
|
59
|
+
export function emitHookBlock(reason: string): void {
|
|
60
|
+
process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\n`);
|
|
61
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
interface ContentBlock {
|
|
6
|
+
text?: string;
|
|
7
|
+
type: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface TranscriptEntry {
|
|
11
|
+
message?: { content?: unknown };
|
|
12
|
+
type?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isFile(path: string): boolean {
|
|
16
|
+
try {
|
|
17
|
+
return statSync(path).isFile();
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function textBlocks(entry: TranscriptEntry): ContentBlock[] | undefined {
|
|
24
|
+
const content = entry.message?.content;
|
|
25
|
+
if (entry.type !== 'assistant' || !Array.isArray(content)) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const blocks = content.filter(
|
|
29
|
+
(b): b is ContentBlock =>
|
|
30
|
+
typeof b === 'object' &&
|
|
31
|
+
b !== null &&
|
|
32
|
+
(b as ContentBlock).type === 'text',
|
|
33
|
+
);
|
|
34
|
+
return blocks.length > 0 ? blocks : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The last assistant text reply from a Claude Code session, reproducing Claude
|
|
39
|
+
* Code's `/copy` byte-for-byte: the last assistant entry that contains a
|
|
40
|
+
* `text` block, only its `text` block(s) (tool_use / thinking dropped), blocks
|
|
41
|
+
* joined with a blank line, and no trailing newline.
|
|
42
|
+
*
|
|
43
|
+
* @param arg a transcript file, a session UUID under the project dir, or
|
|
44
|
+
* nothing to use the newest session for the current project.
|
|
45
|
+
*/
|
|
46
|
+
export function lastReply(arg?: string): string {
|
|
47
|
+
const claudeDir = process.env.CLAUDE_DIR ?? join(homedir(), '.claude');
|
|
48
|
+
// Claude Code keys transcripts under the project root with "/" → "-".
|
|
49
|
+
// Prefer $CLAUDE_PROJECT_DIR (exported to hook processes) over $PWD.
|
|
50
|
+
const proj = (process.env.CLAUDE_PROJECT_DIR ?? process.cwd()).replaceAll(
|
|
51
|
+
'/',
|
|
52
|
+
'-',
|
|
53
|
+
);
|
|
54
|
+
const projDir = join(claudeDir, 'projects', proj);
|
|
55
|
+
|
|
56
|
+
let file: string | undefined;
|
|
57
|
+
if (arg !== undefined) {
|
|
58
|
+
file = isFile(arg) ? arg : join(projDir, `${arg}.jsonl`);
|
|
59
|
+
} else if (existsSync(projDir)) {
|
|
60
|
+
const newest = readdirSync(projDir)
|
|
61
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
62
|
+
.map(f => ({ f, mtimeMs: statSync(join(projDir, f)).mtimeMs }))
|
|
63
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
64
|
+
.at(0);
|
|
65
|
+
if (newest) {
|
|
66
|
+
file = join(projDir, newest.f);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (file === undefined || !isFile(file)) {
|
|
71
|
+
throw new Error(`no session transcript found in ${projDir}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let last: ContentBlock[] | undefined;
|
|
75
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
76
|
+
if (!line.includes('"assistant"')) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
let entry: TranscriptEntry;
|
|
80
|
+
try {
|
|
81
|
+
entry = JSON.parse(line) as TranscriptEntry;
|
|
82
|
+
} catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const blocks = textBlocks(entry);
|
|
86
|
+
if (blocks) {
|
|
87
|
+
last = blocks;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return (last ?? []).map(b => b.text ?? '').join('\n\n');
|
|
91
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain-text rendering primitives shared by the zai and tokens report
|
|
3
|
+
* renderers. Output targets a monospace terminal / hook-block `reason`, so
|
|
4
|
+
* everything here is fixed-width: padding, block-glyph bars, and compact
|
|
5
|
+
* number formatting.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const MONTHS = [
|
|
9
|
+
'Jan',
|
|
10
|
+
'Feb',
|
|
11
|
+
'Mar',
|
|
12
|
+
'Apr',
|
|
13
|
+
'May',
|
|
14
|
+
'Jun',
|
|
15
|
+
'Jul',
|
|
16
|
+
'Aug',
|
|
17
|
+
'Sep',
|
|
18
|
+
'Oct',
|
|
19
|
+
'Nov',
|
|
20
|
+
'Dec',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
|
|
24
|
+
|
|
25
|
+
export function fmtTokens(n: null | number | undefined): string {
|
|
26
|
+
if (n == null || Number.isNaN(n)) {
|
|
27
|
+
return '—';
|
|
28
|
+
}
|
|
29
|
+
if (n >= 1e9) {
|
|
30
|
+
return (n / 1e9).toFixed(1) + 'B';
|
|
31
|
+
}
|
|
32
|
+
if (n >= 1e6) {
|
|
33
|
+
return (n / 1e6).toFixed(1) + 'M';
|
|
34
|
+
}
|
|
35
|
+
if (n >= 1e3) {
|
|
36
|
+
return (n / 1e3).toFixed(1) + 'K';
|
|
37
|
+
}
|
|
38
|
+
return String(n);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function fmtNum(n: null | number | undefined): string {
|
|
42
|
+
return (n || 0).toLocaleString('en-US');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function padR(s: string, n: number): string {
|
|
46
|
+
return s.length >= n ? s : s + ' '.repeat(n - s.length);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function padL(s: string, n: number): string {
|
|
50
|
+
return s.length >= n ? s : ' '.repeat(n - s.length) + s;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Fixed-width bar field (width cols): █ blocks + an eighth-fraction + trailing spaces. */
|
|
54
|
+
export function barField(v: number, max: number, width: number): string {
|
|
55
|
+
if (!v || v <= 0 || max <= 0) {
|
|
56
|
+
return ' '.repeat(width);
|
|
57
|
+
}
|
|
58
|
+
const scaled = (v / max) * width;
|
|
59
|
+
let full = Math.floor(scaled);
|
|
60
|
+
let fi = Math.round((scaled - full) * 8);
|
|
61
|
+
if (fi === 8) {
|
|
62
|
+
full += 1;
|
|
63
|
+
fi = 0;
|
|
64
|
+
}
|
|
65
|
+
if (full === 0 && fi === 0) {
|
|
66
|
+
fi = 1;
|
|
67
|
+
} // keep a sliver for any nonzero value
|
|
68
|
+
let s = '█'.repeat(Math.min(full, width));
|
|
69
|
+
if (full < width && fi > 0) {
|
|
70
|
+
s += EIGHTHS[fi];
|
|
71
|
+
}
|
|
72
|
+
if (s.length < width) {
|
|
73
|
+
s += ' '.repeat(width - s.length);
|
|
74
|
+
}
|
|
75
|
+
return s.slice(0, width);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Filled/empty meter: █ for used, ░ for remaining. */
|
|
79
|
+
export function meter(pct: number | undefined, width: number): string {
|
|
80
|
+
let filled = Math.round(((pct || 0) / 100) * width);
|
|
81
|
+
filled = Math.max(0, Math.min(width, filled));
|
|
82
|
+
return '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
83
|
+
}
|