@v1nvn/agentic-core 0.25.0 → 0.26.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/README.md +12 -6
- package/dist/index.js +134 -30
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
- package/src/cli.ts +5 -4
- package/src/dev.ts +46 -0
- package/src/hook.ts +10 -15
- package/src/index.ts +4 -0
- package/src/input.ts +30 -0
- package/src/last-reply.ts +8 -10
- package/src/logger.ts +59 -0
- package/src/shutdown.ts +28 -0
- package/src/text-format.ts +15 -0
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# @v1nvn/agentic-core
|
|
2
2
|
|
|
3
|
-
The shared runtime of the `@v1nvn` agent tools:
|
|
4
|
-
|
|
5
|
-
A library — no bin, not directly runnable;
|
|
6
|
-
|
|
3
|
+
The shared runtime of the `@v1nvn` agent tools: transcript reading, CLI and
|
|
4
|
+
hook plumbing, report formatting, logging, and the MCP server lifecycle
|
|
5
|
+
helpers. A library — no bin, not directly runnable; every other package
|
|
6
|
+
consumes it as a workspace dependency.
|
|
7
7
|
|
|
8
8
|
User-facing docs: [root README](../../README.md).
|
|
9
9
|
|
|
@@ -12,9 +12,13 @@ User-facing docs: [root README](../../README.md).
|
|
|
12
12
|
| File | Role |
|
|
13
13
|
|---|---|
|
|
14
14
|
| `src/last-reply.ts` | finds the previous assistant reply across local Claude Code transcripts |
|
|
15
|
-
| `src/
|
|
16
|
-
| `src/
|
|
15
|
+
| `src/input.ts` | stdin and markdown-file input with the CLI error contracts |
|
|
16
|
+
| `src/text-format.ts` | fixed-width tables, rules, bars, and date/pad helpers the terminals render identically |
|
|
17
|
+
| `src/cli.ts` | quiet commander parsing and usage/exit helpers shared by the tool bins |
|
|
17
18
|
| `src/hook.ts` | the `UserPromptExpansion` hook plumbing the zero-token plugins ride |
|
|
19
|
+
| `src/logger.ts` | level-filtered stderr logger for the MCP servers |
|
|
20
|
+
| `src/shutdown.ts` | close-then-exit signal handling for the server entrypoints |
|
|
21
|
+
| `src/dev.ts` | the hot-reload watcher wiring the servers' `yarn dev` harnesses share |
|
|
18
22
|
| `src/index.ts` | the public entry — re-exports the modules above |
|
|
19
23
|
|
|
20
24
|
## Develop
|
|
@@ -30,3 +34,5 @@ yarn lint && yarn typecheck # from the repo root
|
|
|
30
34
|
- No tool-specific logic lives here — a concern lands in core only when two
|
|
31
35
|
or more tools need it.
|
|
32
36
|
- `last-reply` reads transcripts only; it never writes.
|
|
37
|
+
- The logger and shutdown/dev helpers write to stderr only — stdout carries
|
|
38
|
+
the MCP transport on the servers.
|
package/dist/index.js
CHANGED
|
@@ -1,40 +1,53 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
//#region src/
|
|
5
|
-
|
|
6
|
-
|
|
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) => {
|
|
4
|
+
//#region src/input.ts
|
|
5
|
+
function readAll(stream) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
11
7
|
let data = "";
|
|
12
8
|
stream.setEncoding("utf8");
|
|
13
9
|
stream.on("data", (chunk) => {
|
|
14
10
|
data += chunk;
|
|
15
11
|
});
|
|
16
12
|
stream.on("end", () => {
|
|
17
|
-
|
|
18
|
-
resolve(JSON.parse(data));
|
|
19
|
-
} catch {
|
|
20
|
-
resolve({});
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
stream.on("error", () => {
|
|
24
|
-
resolve({});
|
|
13
|
+
resolve(data);
|
|
25
14
|
});
|
|
15
|
+
stream.on("error", reject);
|
|
26
16
|
});
|
|
27
17
|
}
|
|
18
|
+
function readStdin(stream = process.stdin) {
|
|
19
|
+
return readAll(stream);
|
|
20
|
+
}
|
|
21
|
+
/** A markdown file's contents, with the CLI's no-such-file error. */
|
|
22
|
+
function readMarkdownFile(path) {
|
|
23
|
+
if (!statSync(path, { throwIfNoEntry: false })?.isFile()) throw new Error(`no such file: ${path}`);
|
|
24
|
+
return readFileSync(path, "utf8");
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/hook.ts
|
|
28
|
+
/**
|
|
29
|
+
* Read the Claude Code hook event JSON from stdin; empty or unparseable input
|
|
30
|
+
* yields an empty event, never a thrown error — a hook must always answer.
|
|
31
|
+
*/
|
|
32
|
+
function readHookEvent(stream = process.stdin) {
|
|
33
|
+
return readAll(stream).then((data) => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(data);
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}, () => ({}));
|
|
40
|
+
}
|
|
28
41
|
/**
|
|
29
42
|
* Which transcript lastReply should read, per the hook event: transcript_path
|
|
30
43
|
* when it names a real file, else session_id, else nothing (lastReply then
|
|
31
44
|
* falls back to the newest session for the current project).
|
|
32
45
|
*/
|
|
33
46
|
function replyTarget(event) {
|
|
34
|
-
if (event.transcript_path !== void 0 && isFile
|
|
47
|
+
if (event.transcript_path !== void 0 && isFile(event.transcript_path)) return event.transcript_path;
|
|
35
48
|
return event.session_id || void 0;
|
|
36
49
|
}
|
|
37
|
-
function isFile
|
|
50
|
+
function isFile(path) {
|
|
38
51
|
try {
|
|
39
52
|
return statSync(path).isFile();
|
|
40
53
|
} catch {
|
|
@@ -53,15 +66,15 @@ function emitHookBlock(reason) {
|
|
|
53
66
|
}
|
|
54
67
|
//#endregion
|
|
55
68
|
//#region src/cli.ts
|
|
56
|
-
function parseQuietly(program, args) {
|
|
69
|
+
function parseQuietly(program, args, recover) {
|
|
57
70
|
try {
|
|
58
71
|
program.allowExcessArguments(false).exitOverride().configureOutput({
|
|
59
72
|
writeOut: () => void 0,
|
|
60
73
|
writeErr: () => void 0
|
|
61
74
|
}).parse([...args], { from: "user" });
|
|
62
75
|
return program;
|
|
63
|
-
} catch {
|
|
64
|
-
return;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return recover?.(err);
|
|
65
78
|
}
|
|
66
79
|
}
|
|
67
80
|
function printUsageAndExit(program) {
|
|
@@ -87,20 +100,45 @@ async function hookOrPrint(hook, failure, run, stdin = process.stdin) {
|
|
|
87
100
|
}
|
|
88
101
|
}
|
|
89
102
|
//#endregion
|
|
90
|
-
//#region src/
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
103
|
+
//#region src/dev.ts
|
|
104
|
+
var RELOAD_DEBOUNCE_MS = 150;
|
|
105
|
+
var IGNORED_SEGMENTS = [
|
|
106
|
+
"/node_modules/",
|
|
107
|
+
"/dist/",
|
|
108
|
+
"/.git/",
|
|
109
|
+
"/coverage/"
|
|
110
|
+
];
|
|
111
|
+
function shouldReload(file) {
|
|
112
|
+
return IGNORED_SEGMENTS.every((seg) => !file.includes(seg));
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Debounced file-change reloads, serialized via a promise chain: concurrent
|
|
116
|
+
* saves never overlap, and a mid-reload save still produces a follow-up.
|
|
117
|
+
*/
|
|
118
|
+
function watchWithReload(vite, reload) {
|
|
119
|
+
let chain = Promise.resolve();
|
|
120
|
+
let timer;
|
|
121
|
+
vite.watcher.on("change", (file) => {
|
|
122
|
+
if (!shouldReload(file)) return;
|
|
123
|
+
if (timer) clearTimeout(timer);
|
|
124
|
+
timer = setTimeout(() => {
|
|
125
|
+
timer = void 0;
|
|
126
|
+
chain = chain.then(reload);
|
|
127
|
+
}, RELOAD_DEBOUNCE_MS);
|
|
128
|
+
});
|
|
97
129
|
}
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/last-reply.ts
|
|
98
132
|
function textBlocks(entry) {
|
|
99
133
|
const content = entry.message?.content;
|
|
100
134
|
if (entry.type !== "assistant" || !Array.isArray(content)) return;
|
|
101
135
|
const blocks = content.filter((b) => typeof b === "object" && b !== null && b.type === "text");
|
|
102
136
|
return blocks.length > 0 ? blocks : void 0;
|
|
103
137
|
}
|
|
138
|
+
function claudeProjectsDir() {
|
|
139
|
+
const claudeDir = process.env.CLAUDE_DIR ?? join(homedir(), ".claude");
|
|
140
|
+
return join(claudeDir, "projects");
|
|
141
|
+
}
|
|
104
142
|
/**
|
|
105
143
|
* The last assistant text reply from a Claude Code session, reproducing Claude
|
|
106
144
|
* Code's `/copy` byte-for-byte: the last assistant entry that contains a
|
|
@@ -111,9 +149,8 @@ function textBlocks(entry) {
|
|
|
111
149
|
* nothing to use the newest session for the current project.
|
|
112
150
|
*/
|
|
113
151
|
function lastReply(arg) {
|
|
114
|
-
const claudeDir = process.env.CLAUDE_DIR ?? join(homedir(), ".claude");
|
|
115
152
|
const proj = (process.env.CLAUDE_PROJECT_DIR ?? process.cwd()).replaceAll("/", "-");
|
|
116
|
-
const projDir = join(
|
|
153
|
+
const projDir = join(claudeProjectsDir(), proj);
|
|
117
154
|
let file;
|
|
118
155
|
if (arg !== void 0) file = isFile(arg) ? arg : join(projDir, `${arg}.jsonl`);
|
|
119
156
|
else if (existsSync(projDir)) {
|
|
@@ -139,6 +176,62 @@ function lastReply(arg) {
|
|
|
139
176
|
return (last ?? []).map((b) => b.text ?? "").join("\n\n");
|
|
140
177
|
}
|
|
141
178
|
//#endregion
|
|
179
|
+
//#region src/logger.ts
|
|
180
|
+
var LEVEL_RANK = {
|
|
181
|
+
debug: 10,
|
|
182
|
+
info: 20,
|
|
183
|
+
warn: 30,
|
|
184
|
+
error: 40,
|
|
185
|
+
silent: Number.MAX_SAFE_INTEGER
|
|
186
|
+
};
|
|
187
|
+
function levelEnabled(active, wanted) {
|
|
188
|
+
return LEVEL_RANK[wanted] >= LEVEL_RANK[active];
|
|
189
|
+
}
|
|
190
|
+
var LEVEL_LABEL = {
|
|
191
|
+
debug: "DEBUG",
|
|
192
|
+
error: "ERROR",
|
|
193
|
+
info: "INFO",
|
|
194
|
+
warn: "WARN"
|
|
195
|
+
};
|
|
196
|
+
var Logger = class {
|
|
197
|
+
activeLevel;
|
|
198
|
+
stderr;
|
|
199
|
+
constructor(activeLevel, stderr = (line) => process.stderr.write(`${line}\n`)) {
|
|
200
|
+
this.activeLevel = activeLevel;
|
|
201
|
+
this.stderr = stderr;
|
|
202
|
+
}
|
|
203
|
+
debug(message) {
|
|
204
|
+
this.log("debug", message);
|
|
205
|
+
}
|
|
206
|
+
error(message) {
|
|
207
|
+
this.log("error", message);
|
|
208
|
+
}
|
|
209
|
+
info(message) {
|
|
210
|
+
this.log("info", message);
|
|
211
|
+
}
|
|
212
|
+
warn(message) {
|
|
213
|
+
this.log("warn", message);
|
|
214
|
+
}
|
|
215
|
+
log(level, message) {
|
|
216
|
+
if (levelEnabled(this.activeLevel, level)) this.stderr(`${LEVEL_LABEL[level]} ${message}`);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region src/shutdown.ts
|
|
221
|
+
/** Close each closable in order (best-effort), then force-exit. */
|
|
222
|
+
function shutdownOnSignals(closables, signals = ["SIGINT", "SIGTERM"]) {
|
|
223
|
+
const shutdown = () => {
|
|
224
|
+
closables.reduce((acc, closable) => acc.then(async () => {
|
|
225
|
+
try {
|
|
226
|
+
await closable.close();
|
|
227
|
+
} catch {}
|
|
228
|
+
}), Promise.resolve()).finally(() => {
|
|
229
|
+
process.exit(0);
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
for (const signal of signals) process.on(signal, shutdown);
|
|
233
|
+
}
|
|
234
|
+
//#endregion
|
|
142
235
|
//#region src/text-format.ts
|
|
143
236
|
/**
|
|
144
237
|
* Plain-text rendering primitives shared by the zai and tokens report
|
|
@@ -170,6 +263,17 @@ var EIGHTHS = [
|
|
|
170
263
|
"▊",
|
|
171
264
|
"▉"
|
|
172
265
|
];
|
|
266
|
+
var RULE_WIDTH = 68;
|
|
267
|
+
function rule() {
|
|
268
|
+
return "─".repeat(68);
|
|
269
|
+
}
|
|
270
|
+
function pad2(n) {
|
|
271
|
+
return String(n).padStart(2, "0");
|
|
272
|
+
}
|
|
273
|
+
/** Local-time `YYYY-MM-DD` key. */
|
|
274
|
+
function ymd(date) {
|
|
275
|
+
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
276
|
+
}
|
|
173
277
|
function fmtTokens(n) {
|
|
174
278
|
if (n == null || Number.isNaN(n)) return "—";
|
|
175
279
|
if (n >= 1e9) return (n / 1e9).toFixed(1) + "B";
|
|
@@ -209,6 +313,6 @@ function meter(pct, width) {
|
|
|
209
313
|
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
210
314
|
}
|
|
211
315
|
//#endregion
|
|
212
|
-
export { MONTHS, barField, emitHookBlock, fmtNum, fmtTokens, hookOrPrint, lastReply, meter, padL, padR, parseQuietly, printUsageAndExit, readHookEvent, replyTarget };
|
|
316
|
+
export { Logger, MONTHS, RULE_WIDTH, barField, claudeProjectsDir, emitHookBlock, fmtNum, fmtTokens, hookOrPrint, isFile, lastReply, levelEnabled, meter, pad2, padL, padR, parseQuietly, printUsageAndExit, readAll, readHookEvent, readMarkdownFile, readStdin, replyTarget, rule, shutdownOnSignals, watchWithReload, ymd };
|
|
213
317
|
|
|
214
318
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/hook.ts","../src/cli.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 type { HookEvent } from './hook.js';\n\nimport { emitHookBlock, readHookEvent } from './hook.js';\n\nimport type { Command } from 'commander';\n\nexport function parseQuietly(\n program: Command,\n args: readonly string[],\n): Command | undefined {\n try {\n program\n .allowExcessArguments(false)\n .exitOverride()\n .configureOutput({ writeOut: () => undefined, writeErr: () => undefined })\n .parse([...args], { from: 'user' });\n return program;\n } catch {\n return undefined;\n }\n}\n\nexport function printUsageAndExit(program: Command): never {\n console.error(program.helpInformation());\n // CLIs report failure through the exit code; the rule targets libraries.\n // eslint-disable-next-line n/no-process-exit\n process.exit(1);\n}\n\nexport async function hookOrPrint(\n hook: boolean,\n failure: string,\n run: (event: HookEvent | undefined) => Promise<string> | string,\n stdin: NodeJS.ReadableStream = process.stdin,\n): Promise<void> {\n if (hook) {\n let reason: string;\n try {\n reason = await run(await readHookEvent(stdin));\n } catch (e) {\n reason = `${failure}: ${(e as Error).message}`;\n }\n emitHookBlock(reason);\n return;\n }\n try {\n console.log(await run(undefined));\n } catch (e) {\n console.error((e as Error).message);\n // CLIs report failure through the exit code; the rule targets libraries.\n // eslint-disable-next-line n/no-process-exit\n process.exit(1);\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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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/input.ts","../src/hook.ts","../src/cli.ts","../src/dev.ts","../src/last-reply.ts","../src/logger.ts","../src/shutdown.ts","../src/text-format.ts"],"sourcesContent":["import { readFileSync, statSync } from 'node:fs';\n\nexport function readAll(stream: NodeJS.ReadableStream): Promise<string> {\n return new Promise((resolve, reject) => {\n let data = '';\n stream.setEncoding('utf8');\n stream.on('data', (chunk: string) => {\n data += chunk;\n });\n stream.on('end', () => {\n resolve(data);\n });\n stream.on('error', reject);\n });\n}\n\nexport function readStdin(\n stream: NodeJS.ReadableStream = process.stdin,\n): Promise<string> {\n return readAll(stream);\n}\n\n/** A markdown file's contents, with the CLI's no-such-file error. */\nexport function readMarkdownFile(path: string): string {\n const stats = statSync(path, { throwIfNoEntry: false });\n if (!stats?.isFile()) {\n throw new Error(`no such file: ${path}`);\n }\n return readFileSync(path, 'utf8');\n}\n","import { statSync } from 'node:fs';\n\nimport { readAll } from './input.js';\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 readAll(stream).then(\n data => {\n try {\n return JSON.parse(data) as HookEvent;\n } catch {\n return {};\n }\n },\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\nexport function 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 type { HookEvent } from './hook.js';\n\nimport { emitHookBlock, readHookEvent } from './hook.js';\n\nimport type { Command } from 'commander';\n\nexport function parseQuietly<T = never>(\n program: Command,\n args: readonly string[],\n recover?: (err: unknown) => T | undefined,\n): Command | T | undefined {\n try {\n program\n .allowExcessArguments(false)\n .exitOverride()\n .configureOutput({ writeOut: () => undefined, writeErr: () => undefined })\n .parse([...args], { from: 'user' });\n return program;\n } catch (err) {\n return recover?.(err);\n }\n}\n\nexport function printUsageAndExit(program: Command): never {\n console.error(program.helpInformation());\n // CLIs report failure through the exit code; the rule targets libraries.\n // eslint-disable-next-line n/no-process-exit\n process.exit(1);\n}\n\nexport async function hookOrPrint(\n hook: boolean,\n failure: string,\n run: (event: HookEvent | undefined) => Promise<string> | string,\n stdin: NodeJS.ReadableStream = process.stdin,\n): Promise<void> {\n if (hook) {\n let reason: string;\n try {\n reason = await run(await readHookEvent(stdin));\n } catch (e) {\n reason = `${failure}: ${(e as Error).message}`;\n }\n emitHookBlock(reason);\n return;\n }\n try {\n console.log(await run(undefined));\n } catch (e) {\n console.error((e as Error).message);\n // CLIs report failure through the exit code; the rule targets libraries.\n // eslint-disable-next-line n/no-process-exit\n process.exit(1);\n }\n}\n","// Structural stand-ins for vite's dev server and module runner — the shared\n// hot-reload wiring below must not pull vite into core's runtime surface; the\n// MCP packages pass their real instances through.\nexport interface ReloadableRunner {\n readonly evaluatedModules: { clear(): void };\n import<T>(entry: string): Promise<T>;\n}\n\nexport interface WatchableDevServer {\n close(): Promise<unknown>;\n readonly watcher: {\n on(event: 'change', cb: (file: string) => void): unknown;\n };\n}\n\nconst RELOAD_DEBOUNCE_MS = 150;\n\nconst IGNORED_SEGMENTS = ['/node_modules/', '/dist/', '/.git/', '/coverage/'];\n\nfunction shouldReload(file: string): boolean {\n return IGNORED_SEGMENTS.every(seg => !file.includes(seg));\n}\n\n/**\n * Debounced file-change reloads, serialized via a promise chain: concurrent\n * saves never overlap, and a mid-reload save still produces a follow-up.\n */\nexport function watchWithReload(\n vite: WatchableDevServer,\n reload: () => Promise<void>,\n): void {\n let chain: Promise<void> = Promise.resolve();\n let timer: NodeJS.Timeout | undefined;\n vite.watcher.on('change', file => {\n if (!shouldReload(file)) {\n return;\n }\n if (timer) {\n clearTimeout(timer);\n }\n timer = setTimeout(() => {\n timer = undefined;\n chain = chain.then(reload);\n }, RELOAD_DEBOUNCE_MS);\n });\n}\n","import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nimport { isFile } from './hook.js';\n\ninterface ContentBlock {\n text?: string;\n type: string;\n}\n\ninterface TranscriptEntry {\n message?: { content?: unknown };\n type?: string;\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\nexport function claudeProjectsDir(): string {\n const claudeDir = process.env.CLAUDE_DIR ?? join(homedir(), '.claude');\n return join(claudeDir, 'projects');\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 // 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(claudeProjectsDir(), 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","export type LogLevel = 'debug' | 'error' | 'info' | 'silent' | 'warn';\n\nconst LEVEL_RANK: Record<LogLevel, number> = {\n debug: 10,\n info: 20,\n warn: 30,\n error: 40,\n silent: Number.MAX_SAFE_INTEGER,\n};\n\nexport function levelEnabled(\n active: LogLevel,\n wanted: Exclude<LogLevel, 'silent'>,\n): boolean {\n return LEVEL_RANK[wanted] >= LEVEL_RANK[active];\n}\n\nconst LEVEL_LABEL: Record<Exclude<LogLevel, 'silent'>, string> = {\n debug: 'DEBUG',\n error: 'ERROR',\n info: 'INFO',\n warn: 'WARN',\n};\n\ntype Writer = (message: string) => void;\n\n// stdout carries the MCP transport; everything here goes to stderr.\nexport class Logger {\n private readonly stderr: Writer;\n\n constructor(\n readonly activeLevel: LogLevel,\n stderr: Writer = line => process.stderr.write(`${line}\\n`),\n ) {\n this.stderr = stderr;\n }\n\n debug(message: string): void {\n this.log('debug', message);\n }\n\n error(message: string): void {\n this.log('error', message);\n }\n\n info(message: string): void {\n this.log('info', message);\n }\n\n warn(message: string): void {\n this.log('warn', message);\n }\n\n private log(level: Exclude<LogLevel, 'silent'>, message: string): void {\n if (levelEnabled(this.activeLevel, level)) {\n this.stderr(`${LEVEL_LABEL[level]} ${message}`);\n }\n }\n}\n","/** Close each closable in order (best-effort), then force-exit. */\nexport function shutdownOnSignals(\n closables: readonly { close(): Promise<unknown> }[],\n signals: readonly NodeJS.Signals[] = ['SIGINT', 'SIGTERM'],\n): void {\n const shutdown = (): void => {\n void closables\n .reduce(\n (acc: Promise<void>, closable) =>\n acc.then(async () => {\n try {\n await closable.close();\n } catch {\n // best-effort; exiting regardless\n }\n }),\n Promise.resolve(),\n )\n .finally(() => {\n // Force-exit on signal; the n/no-process-exit rule targets libraries.\n // eslint-disable-next-line n/no-process-exit\n process.exit(0);\n });\n };\n for (const signal of signals) {\n process.on(signal, shutdown);\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 const RULE_WIDTH = 68;\n\nexport function rule(): string {\n return '─'.repeat(RULE_WIDTH);\n}\n\nexport function pad2(n: number): string {\n return String(n).padStart(2, '0');\n}\n\n/** Local-time `YYYY-MM-DD` key. */\nexport function ymd(date: Date): string {\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n}\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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@v1nvn/agentic-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Shared runtime for the @v1nvn agent tools — last-reply transcript reader and fixed-width text formatting.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
"transcripts",
|
|
21
21
|
"cli"
|
|
22
22
|
],
|
|
23
|
-
"author":
|
|
23
|
+
"author": {
|
|
24
|
+
"name": "v1nvn",
|
|
25
|
+
"email": "v1n@outlook.com"
|
|
26
|
+
},
|
|
24
27
|
"license": "MIT",
|
|
25
28
|
"repository": {
|
|
26
29
|
"type": "git",
|
package/src/cli.ts
CHANGED
|
@@ -4,10 +4,11 @@ import { emitHookBlock, readHookEvent } from './hook.js';
|
|
|
4
4
|
|
|
5
5
|
import type { Command } from 'commander';
|
|
6
6
|
|
|
7
|
-
export function parseQuietly(
|
|
7
|
+
export function parseQuietly<T = never>(
|
|
8
8
|
program: Command,
|
|
9
9
|
args: readonly string[],
|
|
10
|
-
|
|
10
|
+
recover?: (err: unknown) => T | undefined,
|
|
11
|
+
): Command | T | undefined {
|
|
11
12
|
try {
|
|
12
13
|
program
|
|
13
14
|
.allowExcessArguments(false)
|
|
@@ -15,8 +16,8 @@ export function parseQuietly(
|
|
|
15
16
|
.configureOutput({ writeOut: () => undefined, writeErr: () => undefined })
|
|
16
17
|
.parse([...args], { from: 'user' });
|
|
17
18
|
return program;
|
|
18
|
-
} catch {
|
|
19
|
-
return
|
|
19
|
+
} catch (err) {
|
|
20
|
+
return recover?.(err);
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
|
package/src/dev.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Structural stand-ins for vite's dev server and module runner — the shared
|
|
2
|
+
// hot-reload wiring below must not pull vite into core's runtime surface; the
|
|
3
|
+
// MCP packages pass their real instances through.
|
|
4
|
+
export interface ReloadableRunner {
|
|
5
|
+
readonly evaluatedModules: { clear(): void };
|
|
6
|
+
import<T>(entry: string): Promise<T>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface WatchableDevServer {
|
|
10
|
+
close(): Promise<unknown>;
|
|
11
|
+
readonly watcher: {
|
|
12
|
+
on(event: 'change', cb: (file: string) => void): unknown;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const RELOAD_DEBOUNCE_MS = 150;
|
|
17
|
+
|
|
18
|
+
const IGNORED_SEGMENTS = ['/node_modules/', '/dist/', '/.git/', '/coverage/'];
|
|
19
|
+
|
|
20
|
+
function shouldReload(file: string): boolean {
|
|
21
|
+
return IGNORED_SEGMENTS.every(seg => !file.includes(seg));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Debounced file-change reloads, serialized via a promise chain: concurrent
|
|
26
|
+
* saves never overlap, and a mid-reload save still produces a follow-up.
|
|
27
|
+
*/
|
|
28
|
+
export function watchWithReload(
|
|
29
|
+
vite: WatchableDevServer,
|
|
30
|
+
reload: () => Promise<void>,
|
|
31
|
+
): void {
|
|
32
|
+
let chain: Promise<void> = Promise.resolve();
|
|
33
|
+
let timer: NodeJS.Timeout | undefined;
|
|
34
|
+
vite.watcher.on('change', file => {
|
|
35
|
+
if (!shouldReload(file)) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (timer) {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
}
|
|
41
|
+
timer = setTimeout(() => {
|
|
42
|
+
timer = undefined;
|
|
43
|
+
chain = chain.then(reload);
|
|
44
|
+
}, RELOAD_DEBOUNCE_MS);
|
|
45
|
+
});
|
|
46
|
+
}
|
package/src/hook.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { statSync } from 'node:fs';
|
|
2
2
|
|
|
3
|
+
import { readAll } from './input.js';
|
|
4
|
+
|
|
3
5
|
export interface HookEvent {
|
|
4
6
|
cwd?: string;
|
|
5
7
|
session_id?: string;
|
|
@@ -13,23 +15,16 @@ export interface HookEvent {
|
|
|
13
15
|
export function readHookEvent(
|
|
14
16
|
stream: NodeJS.ReadableStream = process.stdin,
|
|
15
17
|
): Promise<HookEvent> {
|
|
16
|
-
return
|
|
17
|
-
|
|
18
|
-
stream.setEncoding('utf8');
|
|
19
|
-
stream.on('data', (chunk: string) => {
|
|
20
|
-
data += chunk;
|
|
21
|
-
});
|
|
22
|
-
stream.on('end', () => {
|
|
18
|
+
return readAll(stream).then(
|
|
19
|
+
data => {
|
|
23
20
|
try {
|
|
24
|
-
|
|
21
|
+
return JSON.parse(data) as HookEvent;
|
|
25
22
|
} catch {
|
|
26
|
-
|
|
23
|
+
return {};
|
|
27
24
|
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
});
|
|
32
|
-
});
|
|
25
|
+
},
|
|
26
|
+
() => ({}),
|
|
27
|
+
);
|
|
33
28
|
}
|
|
34
29
|
|
|
35
30
|
/**
|
|
@@ -44,7 +39,7 @@ export function replyTarget(event: HookEvent): string | undefined {
|
|
|
44
39
|
return event.session_id || undefined;
|
|
45
40
|
}
|
|
46
41
|
|
|
47
|
-
function isFile(path: string): boolean {
|
|
42
|
+
export function isFile(path: string): boolean {
|
|
48
43
|
try {
|
|
49
44
|
return statSync(path).isFile();
|
|
50
45
|
} catch {
|
package/src/index.ts
CHANGED
package/src/input.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
export function readAll(stream: NodeJS.ReadableStream): Promise<string> {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
let data = '';
|
|
6
|
+
stream.setEncoding('utf8');
|
|
7
|
+
stream.on('data', (chunk: string) => {
|
|
8
|
+
data += chunk;
|
|
9
|
+
});
|
|
10
|
+
stream.on('end', () => {
|
|
11
|
+
resolve(data);
|
|
12
|
+
});
|
|
13
|
+
stream.on('error', reject);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readStdin(
|
|
18
|
+
stream: NodeJS.ReadableStream = process.stdin,
|
|
19
|
+
): Promise<string> {
|
|
20
|
+
return readAll(stream);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A markdown file's contents, with the CLI's no-such-file error. */
|
|
24
|
+
export function readMarkdownFile(path: string): string {
|
|
25
|
+
const stats = statSync(path, { throwIfNoEntry: false });
|
|
26
|
+
if (!stats?.isFile()) {
|
|
27
|
+
throw new Error(`no such file: ${path}`);
|
|
28
|
+
}
|
|
29
|
+
return readFileSync(path, 'utf8');
|
|
30
|
+
}
|
package/src/last-reply.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
|
|
5
|
+
import { isFile } from './hook.js';
|
|
6
|
+
|
|
5
7
|
interface ContentBlock {
|
|
6
8
|
text?: string;
|
|
7
9
|
type: string;
|
|
@@ -12,14 +14,6 @@ interface TranscriptEntry {
|
|
|
12
14
|
type?: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
function isFile(path: string): boolean {
|
|
16
|
-
try {
|
|
17
|
-
return statSync(path).isFile();
|
|
18
|
-
} catch {
|
|
19
|
-
return false;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
17
|
function textBlocks(entry: TranscriptEntry): ContentBlock[] | undefined {
|
|
24
18
|
const content = entry.message?.content;
|
|
25
19
|
if (entry.type !== 'assistant' || !Array.isArray(content)) {
|
|
@@ -34,6 +28,11 @@ function textBlocks(entry: TranscriptEntry): ContentBlock[] | undefined {
|
|
|
34
28
|
return blocks.length > 0 ? blocks : undefined;
|
|
35
29
|
}
|
|
36
30
|
|
|
31
|
+
export function claudeProjectsDir(): string {
|
|
32
|
+
const claudeDir = process.env.CLAUDE_DIR ?? join(homedir(), '.claude');
|
|
33
|
+
return join(claudeDir, 'projects');
|
|
34
|
+
}
|
|
35
|
+
|
|
37
36
|
/**
|
|
38
37
|
* The last assistant text reply from a Claude Code session, reproducing Claude
|
|
39
38
|
* Code's `/copy` byte-for-byte: the last assistant entry that contains a
|
|
@@ -44,14 +43,13 @@ function textBlocks(entry: TranscriptEntry): ContentBlock[] | undefined {
|
|
|
44
43
|
* nothing to use the newest session for the current project.
|
|
45
44
|
*/
|
|
46
45
|
export function lastReply(arg?: string): string {
|
|
47
|
-
const claudeDir = process.env.CLAUDE_DIR ?? join(homedir(), '.claude');
|
|
48
46
|
// Claude Code keys transcripts under the project root with "/" → "-".
|
|
49
47
|
// Prefer $CLAUDE_PROJECT_DIR (exported to hook processes) over $PWD.
|
|
50
48
|
const proj = (process.env.CLAUDE_PROJECT_DIR ?? process.cwd()).replaceAll(
|
|
51
49
|
'/',
|
|
52
50
|
'-',
|
|
53
51
|
);
|
|
54
|
-
const projDir = join(
|
|
52
|
+
const projDir = join(claudeProjectsDir(), proj);
|
|
55
53
|
|
|
56
54
|
let file: string | undefined;
|
|
57
55
|
if (arg !== undefined) {
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type LogLevel = 'debug' | 'error' | 'info' | 'silent' | 'warn';
|
|
2
|
+
|
|
3
|
+
const LEVEL_RANK: Record<LogLevel, number> = {
|
|
4
|
+
debug: 10,
|
|
5
|
+
info: 20,
|
|
6
|
+
warn: 30,
|
|
7
|
+
error: 40,
|
|
8
|
+
silent: Number.MAX_SAFE_INTEGER,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function levelEnabled(
|
|
12
|
+
active: LogLevel,
|
|
13
|
+
wanted: Exclude<LogLevel, 'silent'>,
|
|
14
|
+
): boolean {
|
|
15
|
+
return LEVEL_RANK[wanted] >= LEVEL_RANK[active];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const LEVEL_LABEL: Record<Exclude<LogLevel, 'silent'>, string> = {
|
|
19
|
+
debug: 'DEBUG',
|
|
20
|
+
error: 'ERROR',
|
|
21
|
+
info: 'INFO',
|
|
22
|
+
warn: 'WARN',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type Writer = (message: string) => void;
|
|
26
|
+
|
|
27
|
+
// stdout carries the MCP transport; everything here goes to stderr.
|
|
28
|
+
export class Logger {
|
|
29
|
+
private readonly stderr: Writer;
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
readonly activeLevel: LogLevel,
|
|
33
|
+
stderr: Writer = line => process.stderr.write(`${line}\n`),
|
|
34
|
+
) {
|
|
35
|
+
this.stderr = stderr;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
debug(message: string): void {
|
|
39
|
+
this.log('debug', message);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
error(message: string): void {
|
|
43
|
+
this.log('error', message);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
info(message: string): void {
|
|
47
|
+
this.log('info', message);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
warn(message: string): void {
|
|
51
|
+
this.log('warn', message);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private log(level: Exclude<LogLevel, 'silent'>, message: string): void {
|
|
55
|
+
if (levelEnabled(this.activeLevel, level)) {
|
|
56
|
+
this.stderr(`${LEVEL_LABEL[level]} ${message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/shutdown.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Close each closable in order (best-effort), then force-exit. */
|
|
2
|
+
export function shutdownOnSignals(
|
|
3
|
+
closables: readonly { close(): Promise<unknown> }[],
|
|
4
|
+
signals: readonly NodeJS.Signals[] = ['SIGINT', 'SIGTERM'],
|
|
5
|
+
): void {
|
|
6
|
+
const shutdown = (): void => {
|
|
7
|
+
void closables
|
|
8
|
+
.reduce(
|
|
9
|
+
(acc: Promise<void>, closable) =>
|
|
10
|
+
acc.then(async () => {
|
|
11
|
+
try {
|
|
12
|
+
await closable.close();
|
|
13
|
+
} catch {
|
|
14
|
+
// best-effort; exiting regardless
|
|
15
|
+
}
|
|
16
|
+
}),
|
|
17
|
+
Promise.resolve(),
|
|
18
|
+
)
|
|
19
|
+
.finally(() => {
|
|
20
|
+
// Force-exit on signal; the n/no-process-exit rule targets libraries.
|
|
21
|
+
// eslint-disable-next-line n/no-process-exit
|
|
22
|
+
process.exit(0);
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
for (const signal of signals) {
|
|
26
|
+
process.on(signal, shutdown);
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/text-format.ts
CHANGED
|
@@ -22,6 +22,21 @@ export const MONTHS = [
|
|
|
22
22
|
|
|
23
23
|
const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
|
|
24
24
|
|
|
25
|
+
export const RULE_WIDTH = 68;
|
|
26
|
+
|
|
27
|
+
export function rule(): string {
|
|
28
|
+
return '─'.repeat(RULE_WIDTH);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function pad2(n: number): string {
|
|
32
|
+
return String(n).padStart(2, '0');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Local-time `YYYY-MM-DD` key. */
|
|
36
|
+
export function ymd(date: Date): string {
|
|
37
|
+
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
export function fmtTokens(n: null | number | undefined): string {
|
|
26
41
|
if (n == null || Number.isNaN(n)) {
|
|
27
42
|
return '—';
|