vouchington-tooling 0.47.0 → 0.48.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 +5 -1
- package/dist/agent-harness-identity/index.d.mts +3 -25
- package/dist/agent-harness-identity/index.mjs +3 -28
- package/dist/mcp-server/pr-agent.mjs +3 -3
- package/dist/retrospective-transcript/as-array.d.mts +1 -0
- package/dist/retrospective-transcript/as-array.mjs +3 -0
- package/dist/retrospective-transcript/command-prefix.d.mts +1 -0
- package/dist/retrospective-transcript/command-prefix.mjs +124 -0
- package/dist/retrospective-transcript/command-segments.d.mts +2 -0
- package/dist/retrospective-transcript/command-segments.mjs +54 -0
- package/dist/retrospective-transcript/index.d.mts +5 -0
- package/dist/retrospective-transcript/index.mjs +5 -0
- package/dist/retrospective-transcript/resolve-transcript-file.d.mts +10 -0
- package/dist/retrospective-transcript/resolve-transcript-file.mjs +10 -0
- package/dist/retrospective-transcript/transcript-lines.d.mts +22 -0
- package/dist/retrospective-transcript/transcript-lines.mjs +74 -0
- package/package.json +2 -6
package/README.md
CHANGED
|
@@ -165,7 +165,11 @@ pass `--root`, `--workflow-directory`, or `--action-directory` for consumer-owne
|
|
|
165
165
|
Claude-compatible transcript when `CURSOR_SESSION_ID` is set, and Grok's `updates.jsonl` session
|
|
166
166
|
layout when `GROK_SESSION_ID` is set. Use `--grok-sessions-dir` to point discovery at a nondefault
|
|
167
167
|
Grok session root. Without `--session-id`, it reads those session identities from the host
|
|
168
|
-
environment.
|
|
168
|
+
environment. `resolveTranscriptFile` is the canonical resolver: it lowercases and UUID-validates
|
|
169
|
+
session ids, honors `CODEX_HOME`, `CLAUDE_CONFIG_DIR` and `GROK_HOME`, and errors when more than one
|
|
170
|
+
transcript matches. The subpath also exports `normalizeCommandPrefix`, `splitCommandSegments`,
|
|
171
|
+
`isPackageRunner`, `asRecord`, `asArray` and `openTranscriptLines(path, { maxRecordBytes })`, which
|
|
172
|
+
streams lines (2 MiB per record by default) and throws from the iterable on an oversized record.
|
|
169
173
|
|
|
170
174
|
`retrospective-facts` keeps local Git evidence separate from GitHub PR data. `Commits ahead of
|
|
171
175
|
origin/main` is populated only from a local ancestry range; GitHub responses instead populate
|
|
@@ -1,26 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
3
|
-
export
|
|
4
|
-
readonly claude: {
|
|
5
|
-
readonly compatMode: boolean;
|
|
6
|
-
readonly sessionId?: string;
|
|
7
|
-
};
|
|
8
|
-
readonly codex: {
|
|
9
|
-
readonly sessionId?: string;
|
|
10
|
-
};
|
|
11
|
-
readonly cursor: {
|
|
12
|
-
readonly agentMarker: boolean;
|
|
13
|
-
readonly sessionId?: string;
|
|
14
|
-
};
|
|
15
|
-
readonly grok: {
|
|
16
|
-
readonly agentMarker: boolean;
|
|
17
|
-
readonly hookEvent: boolean;
|
|
18
|
-
readonly sessionId?: string;
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
export interface HarnessSessionIdentity {
|
|
22
|
-
readonly harness: HarnessId;
|
|
23
|
-
readonly sessionId: string;
|
|
24
|
-
}
|
|
1
|
+
import { type HarnessEnvironment } from 'agent-blackboard';
|
|
2
|
+
export { HARNESS_IDS, selectHarnessSession } from 'agent-blackboard';
|
|
3
|
+
export type { HarnessEnvironment, HarnessId, HarnessSessionIdentity } from 'agent-blackboard';
|
|
25
4
|
export declare function inspectHarnessEnvironment(env?: NodeJS.ProcessEnv): HarnessEnvironment;
|
|
26
|
-
export declare function selectHarnessSession(environment: HarnessEnvironment, precedence: readonly HarnessId[]): HarnessSessionIdentity | undefined;
|
|
@@ -1,30 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
return value ? { sessionId: value } : {};
|
|
4
|
-
}
|
|
1
|
+
import { inspectHarnessEnvironment as inspect } from 'agent-blackboard';
|
|
2
|
+
export { HARNESS_IDS, selectHarnessSession } from 'agent-blackboard';
|
|
5
3
|
export function inspectHarnessEnvironment(env = process.env) {
|
|
6
|
-
return
|
|
7
|
-
claude: {
|
|
8
|
-
compatMode: env.CLAUDECODE === '1',
|
|
9
|
-
...session(env.CLAUDE_CODE_SESSION_ID),
|
|
10
|
-
},
|
|
11
|
-
codex: session(env.CODEX_THREAD_ID),
|
|
12
|
-
cursor: {
|
|
13
|
-
agentMarker: Boolean(env.CURSOR_AGENT),
|
|
14
|
-
...session(env.CURSOR_SESSION_ID),
|
|
15
|
-
},
|
|
16
|
-
grok: {
|
|
17
|
-
agentMarker: Boolean(env.GROK_AGENT),
|
|
18
|
-
hookEvent: Boolean(env.GROK_HOOK_EVENT),
|
|
19
|
-
...session(env.GROK_SESSION_ID),
|
|
20
|
-
},
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
export function selectHarnessSession(environment, precedence) {
|
|
24
|
-
for (const harness of precedence) {
|
|
25
|
-
const sessionId = environment[harness].sessionId;
|
|
26
|
-
if (sessionId)
|
|
27
|
-
return { harness, sessionId };
|
|
28
|
-
}
|
|
29
|
-
return undefined;
|
|
4
|
+
return inspect(env);
|
|
30
5
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { HARNESS_SESSION_PRECEDENCE } from 'agent-blackboard';
|
|
1
2
|
import { inspectHarnessEnvironment } from "../agent-harness-identity/index.mjs";
|
|
2
|
-
const PRECEDENCE = ['codex', 'claude', 'cursor', 'grok'];
|
|
3
3
|
/**
|
|
4
4
|
* The harness that launched this server, from the launch environment first and the MCP client's
|
|
5
5
|
* `initialize` `clientInfo.name` second. Neither comes from a tool argument.
|
|
@@ -12,11 +12,11 @@ export function deriveAgent(env, clientName) {
|
|
|
12
12
|
cursor: found.cursor.agentMarker || Boolean(found.cursor.sessionId),
|
|
13
13
|
grok: found.grok.agentMarker || found.grok.hookEvent || Boolean(found.grok.sessionId),
|
|
14
14
|
};
|
|
15
|
-
const fromEnv =
|
|
15
|
+
const fromEnv = HARNESS_SESSION_PRECEDENCE.find((id) => present[id]);
|
|
16
16
|
if (fromEnv !== undefined)
|
|
17
17
|
return fromEnv;
|
|
18
18
|
const name = clientName?.toLowerCase() ?? '';
|
|
19
|
-
return
|
|
19
|
+
return HARNESS_SESSION_PRECEDENCE.find((id) => name.includes(id));
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
22
|
* The text of the `Agent:` line before the session. The agent is the derived harness; a caller
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function asArray(value: unknown): unknown[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function normalizeCommandPrefix(command: string): string;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { isPackageRunner, splitCommandSegments } from "./command-segments.mjs";
|
|
2
|
+
// A real command/subcommand token (binary name, git subcommand, short flag, script
|
|
3
|
+
// name) is always well under this length. A token that exceeds it isn't a command
|
|
4
|
+
// token at all — it's payload that got tokenized as one word because it had no
|
|
5
|
+
// internal whitespace (a `VAR={...json...}` assignment, a giant quoted printf arg).
|
|
6
|
+
// Such tokens are replaced with a redaction marker rather than truncated: truncation
|
|
7
|
+
// still leaks a prefix of whatever the token contains (a secret can sit at the front,
|
|
8
|
+
// e.g. "AWS_SECRET_ACCESS_KEY=AKIA..."), while a full-token redaction leaks nothing
|
|
9
|
+
// regardless of where sensitive content falls inside it. This intentionally also
|
|
10
|
+
// redacts long-but-benign tokens (e.g. a `cd /very/long/worktree/path` argument) —
|
|
11
|
+
// for a privacy boundary, over-redacting a legitimate token is the safe failure mode.
|
|
12
|
+
const MAX_COMMAND_TOKEN_LENGTH = 40;
|
|
13
|
+
const REDACTED_TOKEN = '…';
|
|
14
|
+
function redactOverlongToken(token) {
|
|
15
|
+
return token.length > MAX_COMMAND_TOKEN_LENGTH ? REDACTED_TOKEN : token;
|
|
16
|
+
}
|
|
17
|
+
const ENV_ASSIGNMENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
18
|
+
// A segment's real command starts after any leading `VAR=value` assignments (e.g.
|
|
19
|
+
// "CI=1 pnpm exec vitest", "GIT_EDITOR=true git rebase --continue" — both real
|
|
20
|
+
// patterns used in real agent sessions). Without stripping these, the
|
|
21
|
+
// env-var token itself gets normalized as if it were the command, so "CI=1 pnpm exec
|
|
22
|
+
// vitest" would classify as prefix "CI=1 pnpm" instead of "pnpm exec vitest". Only
|
|
23
|
+
// strips when a token remains afterward — a segment that is *entirely*
|
|
24
|
+
// assignment-shaped tokens (e.g. a lone `OLD_POLICY_JSON={...}` token with no
|
|
25
|
+
// whitespace) has no command to recover, and is left alone so it still falls through
|
|
26
|
+
// to the general redaction path below instead of normalizing to an empty prefix.
|
|
27
|
+
function stripEnvPrefix(segment) {
|
|
28
|
+
const index = segment.findIndex((token) => !ENV_ASSIGNMENT_PATTERN.test(token));
|
|
29
|
+
return index === -1 ? segment : segment.slice(index);
|
|
30
|
+
}
|
|
31
|
+
// Git global options precede the real subcommand — "git -C <path> status" and
|
|
32
|
+
// "git status" are the same command family for policy-matching purposes (`git -h`
|
|
33
|
+
// documents `git [-C <path>] [-c <name>=<value>] ... <command> [<args>]`). `-C`/`-c`
|
|
34
|
+
// take a separate-token argument; `--git-dir=<path>`-style options fold their argument
|
|
35
|
+
// into one `=`-joined token; the rest take no argument at all. Skipping them keeps the
|
|
36
|
+
// normalized prefix on the subcommand instead of stalling on the option — without this,
|
|
37
|
+
// e.g. `git -C <worktree> status` normalizes to "git -C" and reports as an uncovered
|
|
38
|
+
// escalation/block candidate under the wrong prefix, even though `git status` is
|
|
39
|
+
// already covered by an existing narrow allow rule.
|
|
40
|
+
const GIT_GLOBAL_OPTIONS_WITH_ARG = new Set(['-C', '-c']);
|
|
41
|
+
const GIT_GLOBAL_OPTIONS_NO_ARG = new Set([
|
|
42
|
+
'-v',
|
|
43
|
+
'--version',
|
|
44
|
+
'-h',
|
|
45
|
+
'--help',
|
|
46
|
+
'--exec-path',
|
|
47
|
+
'--html-path',
|
|
48
|
+
'--man-path',
|
|
49
|
+
'--info-path',
|
|
50
|
+
'-p',
|
|
51
|
+
'--paginate',
|
|
52
|
+
'-P',
|
|
53
|
+
'--no-pager',
|
|
54
|
+
'--no-replace-objects',
|
|
55
|
+
'--bare',
|
|
56
|
+
]);
|
|
57
|
+
const GIT_GLOBAL_OPTION_WITH_INLINE_ARG = /^--[a-z-]+=/;
|
|
58
|
+
function stripGitGlobalOptions(tokens) {
|
|
59
|
+
let index = 0;
|
|
60
|
+
while (index < tokens.length) {
|
|
61
|
+
const token = tokens[index];
|
|
62
|
+
if (GIT_GLOBAL_OPTIONS_WITH_ARG.has(token))
|
|
63
|
+
index += 2;
|
|
64
|
+
else if (GIT_GLOBAL_OPTIONS_NO_ARG.has(token) || GIT_GLOBAL_OPTION_WITH_INLINE_ARG.test(token))
|
|
65
|
+
index += 1;
|
|
66
|
+
else
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
return tokens.slice(index);
|
|
70
|
+
}
|
|
71
|
+
// Normalizes a segment to "<leading token> <first subcommand>" (e.g. "git status",
|
|
72
|
+
// "gh pr", or just "cat" when there's no second token), with one exception that goes
|
|
73
|
+
// one token deeper because a permissions file may distinguish at that depth:
|
|
74
|
+
// package runners — "pnpm run build" vs "pnpm exec build" are different
|
|
75
|
+
// permission entries, so the run/exec target must
|
|
76
|
+
// stay out of the generic two-token rule.
|
|
77
|
+
function normalizeSegment(segment) {
|
|
78
|
+
const [first, ...rest] = segment;
|
|
79
|
+
if (!first)
|
|
80
|
+
return '';
|
|
81
|
+
// A segment that is only assignments (stripEnvPrefix left it intact) has no command;
|
|
82
|
+
// its value may be a secret of any length, so always redact it.
|
|
83
|
+
if (ENV_ASSIGNMENT_PATTERN.test(first))
|
|
84
|
+
return REDACTED_TOKEN;
|
|
85
|
+
if (isPackageRunner(first) && (rest[0] === 'run' || rest[0] === 'exec') && rest[1]) {
|
|
86
|
+
return `${redactOverlongToken(first)} ${rest[0]} ${redactOverlongToken(rest[1])}`;
|
|
87
|
+
}
|
|
88
|
+
const effectiveRest = first === 'git' ? stripGitGlobalOptions(rest) : rest;
|
|
89
|
+
return effectiveRest[0]
|
|
90
|
+
? `${redactOverlongToken(first)} ${redactOverlongToken(effectiveRest[0])}`
|
|
91
|
+
: redactOverlongToken(first);
|
|
92
|
+
}
|
|
93
|
+
// A leading "cd <path> && ..." (or "cd <path>; ...") prelude is not itself the
|
|
94
|
+
// actionable command — it's a workdir change common in Bash sessions with no separate
|
|
95
|
+
// cwd param. Without skipping it, "cd /repo && pnpm install" would normalize to "cd
|
|
96
|
+
// /repo" and both miss existing pnpm policy coverage and leak a checkout path into
|
|
97
|
+
// non-raw retro output. Only a *leading run* of cd segments is skipped — a bare `cd
|
|
98
|
+
// /repo` with nothing after it has no other segment to fall back to, so it still
|
|
99
|
+
// normalizes as-is.
|
|
100
|
+
function skipLeadingCdSegments(segments) {
|
|
101
|
+
let index = 0;
|
|
102
|
+
while (index < segments.length - 1 && stripEnvPrefix(segments[index])[0] === 'cd')
|
|
103
|
+
index++;
|
|
104
|
+
return segments.slice(index);
|
|
105
|
+
}
|
|
106
|
+
// Produces a classification key for a raw shell command, for comparison against the
|
|
107
|
+
// prefix lists in a consumer permissions file (excluded commands, allow,
|
|
108
|
+
// and deny lists). This is a privacy
|
|
109
|
+
// boundary: callers building non---raw report output must emit only this normalized
|
|
110
|
+
// prefix, never the raw `command` string.
|
|
111
|
+
//
|
|
112
|
+
// Only the first non-cd segment of a compound command (`git status && git push`) is
|
|
113
|
+
// normalized — this is a rough per-command categorization tool, not a full command
|
|
114
|
+
// parser, so a compound command is bucketed by its first actionable part.
|
|
115
|
+
//
|
|
116
|
+
// The leading token is preserved as-is (no basename stripping for a path-qualified
|
|
117
|
+
// binary like "/usr/bin/git"):
|
|
118
|
+
// permissions files may also list literal relative-path scripts (e.g. "./scripts/run")
|
|
119
|
+
// that must not be collapsed to a basename.
|
|
120
|
+
export function normalizeCommandPrefix(command) {
|
|
121
|
+
const segments = skipLeadingCdSegments(splitCommandSegments(command));
|
|
122
|
+
const segment = stripEnvPrefix(segments[0] ?? []);
|
|
123
|
+
return normalizeSegment(segment);
|
|
124
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Commands are matched by command position, not by scanning for words anywhere in
|
|
2
|
+
// the string. The string is split into segments on unquoted `;`, `&`, `|` and
|
|
3
|
+
// newlines (so `git push;gh pr view` splits even with no surrounding whitespace).
|
|
4
|
+
// Quoted spans, including any whitespace inside them, are grouped into a single
|
|
5
|
+
// word, mirroring real shell tokenization.
|
|
6
|
+
export function splitCommandSegments(command) {
|
|
7
|
+
const segments = [];
|
|
8
|
+
let current = [];
|
|
9
|
+
let word = '';
|
|
10
|
+
let quote = null;
|
|
11
|
+
const flushWord = () => {
|
|
12
|
+
if (word) {
|
|
13
|
+
current.push(word);
|
|
14
|
+
word = '';
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const flushSegment = () => {
|
|
18
|
+
flushWord();
|
|
19
|
+
if (current.length) {
|
|
20
|
+
segments.push(current);
|
|
21
|
+
current = [];
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
for (const char of command) {
|
|
25
|
+
if (quote) {
|
|
26
|
+
if (char === quote)
|
|
27
|
+
quote = null;
|
|
28
|
+
else
|
|
29
|
+
word += char;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (char === '"' || char === "'") {
|
|
33
|
+
quote = char;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (char === ';' || char === '&' || char === '|' || char === '\n' || char === '\r') {
|
|
37
|
+
flushSegment();
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (/\s/.test(char)) {
|
|
41
|
+
flushWord();
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
word += char;
|
|
45
|
+
}
|
|
46
|
+
flushSegment();
|
|
47
|
+
return segments;
|
|
48
|
+
}
|
|
49
|
+
const PACKAGE_RUNNERS = ['npx', 'pnpm', 'pnpx', 'yarn'];
|
|
50
|
+
export function isPackageRunner(word) {
|
|
51
|
+
if (!word)
|
|
52
|
+
return false;
|
|
53
|
+
return PACKAGE_RUNNERS.some((runner) => word === runner || word.endsWith(`/${runner}`));
|
|
54
|
+
}
|
|
@@ -5,6 +5,11 @@ export { codexChildren, codexIdentity } from './codex.mts';
|
|
|
5
5
|
export { formatTranscriptFacts, formatUnavailable } from './format.mts';
|
|
6
6
|
export type { ResolveOptions } from './resolve-transcript-file.mts';
|
|
7
7
|
export { resolveTranscriptFile } from './resolve-transcript-file.mts';
|
|
8
|
+
export { asRecord } from './shared.mts';
|
|
9
|
+
export { asArray } from './as-array.mts';
|
|
10
|
+
export { normalizeCommandPrefix } from './command-prefix.mts';
|
|
11
|
+
export { isPackageRunner, splitCommandSegments } from './command-segments.mts';
|
|
12
|
+
export { DEFAULT_MAX_RECORD_BYTES, openTranscriptLines } from './transcript-lines.mts';
|
|
8
13
|
export type RetrospectiveTranscriptReport = {
|
|
9
14
|
markdown: string;
|
|
10
15
|
coverage: 'complete' | 'partial' | 'unavailable';
|
|
@@ -11,6 +11,11 @@ import { emptyFacts, hasMalformedInteriorRecord, parseLines, } from "./shared.mj
|
|
|
11
11
|
export { codexChildren, codexIdentity } from "./codex.mjs";
|
|
12
12
|
export { formatTranscriptFacts, formatUnavailable } from "./format.mjs";
|
|
13
13
|
export { resolveTranscriptFile } from "./resolve-transcript-file.mjs";
|
|
14
|
+
export { asRecord } from "./shared.mjs";
|
|
15
|
+
export { asArray } from "./as-array.mjs";
|
|
16
|
+
export { normalizeCommandPrefix } from "./command-prefix.mjs";
|
|
17
|
+
export { isPackageRunner, splitCommandSegments } from "./command-segments.mjs";
|
|
18
|
+
export { DEFAULT_MAX_RECORD_BYTES, openTranscriptLines } from "./transcript-lines.mjs";
|
|
14
19
|
const MALFORMED_INTERIOR = 'malformed interior transcript record';
|
|
15
20
|
function partialCapture(lines) {
|
|
16
21
|
return parseLines(lines).length !== lines.filter((line) => line.trim()).length;
|
|
@@ -15,5 +15,15 @@ type TranscriptResolution = {
|
|
|
15
15
|
error: string;
|
|
16
16
|
};
|
|
17
17
|
export declare function globFrom(root: string, pattern: string): string[];
|
|
18
|
+
/**
|
|
19
|
+
* Canonical transcript resolution for consumers.
|
|
20
|
+
*
|
|
21
|
+
* - Session ids are validated as UUIDs and lowercased (`invalid session id format` otherwise).
|
|
22
|
+
* - Roots honor `CODEX_HOME`, `CLAUDE_CONFIG_DIR` and `GROK_HOME` from `options.env`
|
|
23
|
+
* (default `process.env`), unless an explicit `*SessionsDir`/`projectsDir` is given.
|
|
24
|
+
* - Returns `{ error }` when more than one transcript matches a session, rather than
|
|
25
|
+
* picking the first match, and when none matches.
|
|
26
|
+
* - An explicit `jsonlPath` skips discovery.
|
|
27
|
+
*/
|
|
18
28
|
export declare function resolveTranscriptFile(options: ResolveOptions): TranscriptResolution;
|
|
19
29
|
export {};
|
|
@@ -7,6 +7,16 @@ export const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9
|
|
|
7
7
|
export function globFrom(root, pattern) {
|
|
8
8
|
return globSync(pattern, { cwd: root }).map((path) => join(root, path));
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Canonical transcript resolution for consumers.
|
|
12
|
+
*
|
|
13
|
+
* - Session ids are validated as UUIDs and lowercased (`invalid session id format` otherwise).
|
|
14
|
+
* - Roots honor `CODEX_HOME`, `CLAUDE_CONFIG_DIR` and `GROK_HOME` from `options.env`
|
|
15
|
+
* (default `process.env`), unless an explicit `*SessionsDir`/`projectsDir` is given.
|
|
16
|
+
* - Returns `{ error }` when more than one transcript matches a session, rather than
|
|
17
|
+
* picking the first match, and when none matches.
|
|
18
|
+
* - An explicit `jsonlPath` skips discovery.
|
|
19
|
+
*/
|
|
10
20
|
export function resolveTranscriptFile(options) {
|
|
11
21
|
if (options.sessionId && !SESSION_ID.test(options.sessionId))
|
|
12
22
|
return { error: 'invalid session id format' };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const DEFAULT_MAX_RECORD_BYTES: number;
|
|
2
|
+
export type OpenTranscriptLinesOptions = {
|
|
3
|
+
/** Largest allowed record (one line, excluding its newline) in bytes. Defaults to 2 MiB. */
|
|
4
|
+
maxRecordBytes?: number;
|
|
5
|
+
};
|
|
6
|
+
export type OpenTranscriptLinesResult = {
|
|
7
|
+
lines: AsyncIterable<string>;
|
|
8
|
+
} | {
|
|
9
|
+
error: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Opens a transcript for streaming, one line at a time, with `String.split('\n')`
|
|
13
|
+
* semantics (a trailing newline yields a final empty line, `\r` is preserved).
|
|
14
|
+
*
|
|
15
|
+
* A `maxRecordBytes` that is not a non-negative safe integer rejects with `RangeError`.
|
|
16
|
+
* An open failure resolves to `{ error }`. An oversized record is NOT skipped or
|
|
17
|
+
* reported inline: the returned async iterable throws
|
|
18
|
+
* `transcript record exceeds <maxRecordBytes> byte limit` when it reaches the
|
|
19
|
+
* record, ending iteration, and the underlying stream is destroyed. Lines yielded
|
|
20
|
+
* before the oversized record remain valid.
|
|
21
|
+
*/
|
|
22
|
+
export declare function openTranscriptLines(path: string, { maxRecordBytes }?: OpenTranscriptLinesOptions): Promise<OpenTranscriptLinesResult>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
export const DEFAULT_MAX_RECORD_BYTES = 2 * 1024 * 1024;
|
|
3
|
+
/**
|
|
4
|
+
* Opens a transcript for streaming, one line at a time, with `String.split('\n')`
|
|
5
|
+
* semantics (a trailing newline yields a final empty line, `\r` is preserved).
|
|
6
|
+
*
|
|
7
|
+
* A `maxRecordBytes` that is not a non-negative safe integer rejects with `RangeError`.
|
|
8
|
+
* An open failure resolves to `{ error }`. An oversized record is NOT skipped or
|
|
9
|
+
* reported inline: the returned async iterable throws
|
|
10
|
+
* `transcript record exceeds <maxRecordBytes> byte limit` when it reaches the
|
|
11
|
+
* record, ending iteration, and the underlying stream is destroyed. Lines yielded
|
|
12
|
+
* before the oversized record remain valid.
|
|
13
|
+
*/
|
|
14
|
+
export async function openTranscriptLines(path, { maxRecordBytes = DEFAULT_MAX_RECORD_BYTES } = {}) {
|
|
15
|
+
if (!Number.isSafeInteger(maxRecordBytes) || maxRecordBytes < 0)
|
|
16
|
+
throw new RangeError('maxRecordBytes must be a non-negative integer');
|
|
17
|
+
const stream = createReadStream(path);
|
|
18
|
+
try {
|
|
19
|
+
await new Promise((resolve, reject) => {
|
|
20
|
+
stream.once('open', () => resolve());
|
|
21
|
+
stream.once('error', reject);
|
|
22
|
+
});
|
|
23
|
+
const lines = streamTranscriptLines(stream, maxRecordBytes);
|
|
24
|
+
const release = lines.return.bind(lines);
|
|
25
|
+
// The file is already open, so cancelling before the first next() must still close it;
|
|
26
|
+
// an unstarted generator never reaches its own finally block.
|
|
27
|
+
lines.return = (value) => {
|
|
28
|
+
stream.destroy();
|
|
29
|
+
return release(value);
|
|
30
|
+
};
|
|
31
|
+
return { lines };
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
stream.destroy();
|
|
35
|
+
return { error: err.message };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function* streamTranscriptLines(stream, maxRecordBytes) {
|
|
39
|
+
const pending = [];
|
|
40
|
+
let pendingBytes = 0;
|
|
41
|
+
const tooLarge = () => new Error(`transcript record exceeds ${maxRecordBytes} byte limit`);
|
|
42
|
+
const line = (tail) => {
|
|
43
|
+
const bytes = pendingBytes + (tail?.byteLength ?? 0);
|
|
44
|
+
if (bytes > maxRecordBytes)
|
|
45
|
+
throw tooLarge();
|
|
46
|
+
const value = Buffer.concat(tail ? [...pending, tail] : pending, bytes).toString('utf8');
|
|
47
|
+
pending.length = 0;
|
|
48
|
+
pendingBytes = 0;
|
|
49
|
+
return value;
|
|
50
|
+
};
|
|
51
|
+
try {
|
|
52
|
+
for await (const chunk of stream) {
|
|
53
|
+
let start = 0;
|
|
54
|
+
for (;;) {
|
|
55
|
+
const newline = chunk.indexOf(10, start);
|
|
56
|
+
if (newline === -1)
|
|
57
|
+
break;
|
|
58
|
+
yield line(chunk.subarray(start, newline));
|
|
59
|
+
start = newline + 1;
|
|
60
|
+
}
|
|
61
|
+
if (start < chunk.byteLength) {
|
|
62
|
+
const tail = chunk.subarray(start);
|
|
63
|
+
pendingBytes += tail.byteLength;
|
|
64
|
+
if (pendingBytes > maxRecordBytes)
|
|
65
|
+
throw tooLarge();
|
|
66
|
+
pending.push(tail);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
yield line();
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
stream.destroy();
|
|
73
|
+
}
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vouchington-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"description": "Vouchington CLI and extractable tooling libraries.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -375,6 +375,7 @@
|
|
|
375
375
|
"@types/mdast": "4.0.4",
|
|
376
376
|
"@types/unist": "3.0.3",
|
|
377
377
|
"@typescript-eslint/parser": "^8.71.1",
|
|
378
|
+
"agent-blackboard": "^0.7.0",
|
|
378
379
|
"csv-parse": "7.0.3",
|
|
379
380
|
"csv-stringify": "6.9.0",
|
|
380
381
|
"dockerfile-ast": "0.7.1",
|
|
@@ -390,22 +391,17 @@
|
|
|
390
391
|
"@modelcontextprotocol/sdk": "1.32.1",
|
|
391
392
|
"@types/picomatch": "^4.0.3",
|
|
392
393
|
"@types/spdx-expression-parse": "4.0.0",
|
|
393
|
-
"agent-blackboard": "^0.6.1",
|
|
394
394
|
"no-mistakes": "0.83.0",
|
|
395
395
|
"smol-toml": "1.9.1",
|
|
396
396
|
"typescript5": "npm:typescript@5.9.3"
|
|
397
397
|
},
|
|
398
398
|
"peerDependencies": {
|
|
399
399
|
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
400
|
-
"agent-blackboard": "^0.6.1",
|
|
401
400
|
"no-mistakes": "*",
|
|
402
401
|
"typescript": ">=5",
|
|
403
402
|
"vitest": ">=5"
|
|
404
403
|
},
|
|
405
404
|
"peerDependenciesMeta": {
|
|
406
|
-
"agent-blackboard": {
|
|
407
|
-
"optional": true
|
|
408
|
-
},
|
|
409
405
|
"@modelcontextprotocol/sdk": {
|
|
410
406
|
"optional": true
|
|
411
407
|
},
|