@oortmem/claude-code 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +11 -2
- package/dist/deps.d.ts +41 -0
- package/dist/deps.js +2 -0
- package/dist/fsutil.d.ts +15 -0
- package/dist/fsutil.js +77 -0
- package/dist/hooks.d.ts +48 -0
- package/dist/hooks.js +321 -0
- package/dist/import.d.ts +71 -0
- package/dist/import.js +244 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/install.d.ts +55 -0
- package/dist/install.js +257 -0
- package/dist/log.d.ts +30 -0
- package/dist/log.js +65 -0
- package/dist/queue.d.ts +79 -0
- package/dist/queue.js +351 -0
- package/dist/transcript.d.ts +88 -0
- package/dist/transcript.js +441 -0
- package/package.json +57 -3
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installs Oort's hooks into a Claude Code settings file (usually
|
|
3
|
+
* `~/.claude/settings.json`). Schema, per https://code.claude.com/docs/en/hooks:
|
|
4
|
+
*
|
|
5
|
+
* { "hooks": { "<Event>": [ { "matcher"?: string, "hooks": [ { "type": "command", "command": string, "timeout"?: seconds } ] } ] } }
|
|
6
|
+
*
|
|
7
|
+
* UserPromptSubmit and Stop take no matcher (one would be ignored); SessionStart's
|
|
8
|
+
* matcher filters the session source, and is omitted so it fires on every source.
|
|
9
|
+
*/
|
|
10
|
+
export declare const HOOK_EVENTS: readonly ['UserPromptSubmit', 'Stop', 'SessionStart'];
|
|
11
|
+
export type InstalledEvent = (typeof HOOK_EVENTS)[number];
|
|
12
|
+
/** Seconds. Claude Code's defaults are 30 (UserPromptSubmit) and 600 (others). */
|
|
13
|
+
export declare const HOOK_TIMEOUTS: Record<InstalledEvent, number>;
|
|
14
|
+
/** `cmd` as one shell word: as is when it is plainly safe, otherwise single-quoted. */
|
|
15
|
+
export declare function shellQuote(cmd: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Merges Oort's three hook entries (`<cmd> hook <Event>`) into the settings
|
|
18
|
+
* file, creating it if missing. Idempotent; replaces a stale Oort entry in
|
|
19
|
+
* place; never removes or reorders anyone else's hooks. Writes atomically and
|
|
20
|
+
* keeps a one-time backup of the original at `<settingsPath>.oort-backup`.
|
|
21
|
+
* Refuses (file untouched) settings that are not JSON, or whose `hooks` or
|
|
22
|
+
* hook event values have the wrong shape.
|
|
23
|
+
*/
|
|
24
|
+
export declare function installHooks(settingsPath: string, cmd: string): {
|
|
25
|
+
changed: boolean;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Removes only Oort's entries. A group left empty by the removal goes too; an
|
|
29
|
+
* event array or `hooks` object left empty goes only if Oort created it (it is
|
|
30
|
+
* absent from the pre-install backup). A missing file is left missing.
|
|
31
|
+
*/
|
|
32
|
+
export declare function uninstallHooks(settingsPath: string, cmd: string): {
|
|
33
|
+
changed: boolean;
|
|
34
|
+
};
|
|
35
|
+
export interface HookEventStatus {
|
|
36
|
+
event: InstalledEvent;
|
|
37
|
+
/**
|
|
38
|
+
* `current`: `<cmd> hook <event>` for this bin. `stale`: Oort's entry from
|
|
39
|
+
* another install path. `found`: Oort's entry, with no bin to compare
|
|
40
|
+
* against. `missing`: no Oort entry (a wrapper such as `cd x && oort hook
|
|
41
|
+
* Stop` is the user's own and does not count).
|
|
42
|
+
*/
|
|
43
|
+
state: 'current' | 'stale' | 'found' | 'missing';
|
|
44
|
+
/** The bin the entry runs, unquoted, for `stale` and `found`. */
|
|
45
|
+
command?: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Read-only: which of Oort's hooks the settings file holds, for `oort
|
|
49
|
+
* doctor`. Throws, like `installHooks`, on settings that are not JSON or have
|
|
50
|
+
* the wrong shape.
|
|
51
|
+
*/
|
|
52
|
+
export declare function hookStatus(settingsPath: string, cmd?: string): {
|
|
53
|
+
exists: boolean;
|
|
54
|
+
events: HookEventStatus[];
|
|
55
|
+
};
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { writeFileAtomic } from './fsutil.js';
|
|
4
|
+
/**
|
|
5
|
+
* Installs Oort's hooks into a Claude Code settings file (usually
|
|
6
|
+
* `~/.claude/settings.json`). Schema, per https://code.claude.com/docs/en/hooks:
|
|
7
|
+
*
|
|
8
|
+
* { "hooks": { "<Event>": [ { "matcher"?: string, "hooks": [ { "type": "command", "command": string, "timeout"?: seconds } ] } ] } }
|
|
9
|
+
*
|
|
10
|
+
* UserPromptSubmit and Stop take no matcher (one would be ignored); SessionStart's
|
|
11
|
+
* matcher filters the session source, and is omitted so it fires on every source.
|
|
12
|
+
*/
|
|
13
|
+
export const HOOK_EVENTS = ['UserPromptSubmit', 'Stop', 'SessionStart'];
|
|
14
|
+
/** Seconds. Claude Code's defaults are 30 (UserPromptSubmit) and 600 (others). */
|
|
15
|
+
export const HOOK_TIMEOUTS = {
|
|
16
|
+
UserPromptSubmit: 10,
|
|
17
|
+
Stop: 5,
|
|
18
|
+
SessionStart: 10,
|
|
19
|
+
};
|
|
20
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
21
|
+
/** `cmd` as one shell word: as is when it is plainly safe, otherwise single-quoted. */
|
|
22
|
+
export function shellQuote(cmd) {
|
|
23
|
+
return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(cmd) ? cmd : `'${cmd.replaceAll("'", `'\\''`)}'`;
|
|
24
|
+
}
|
|
25
|
+
const commandFor = (cmd, event) => `${shellQuote(cmd)} hook ${event}`;
|
|
26
|
+
/**
|
|
27
|
+
* Ours, under `event`: exactly `<cmd> hook <event>`, or exactly `oort hook
|
|
28
|
+
* <event>` from any install path (a stale entry from a moved binary). Anything
|
|
29
|
+
* with more around it (`cd x && oort hook Stop`, `oort hook Stop --flag`) is
|
|
30
|
+
* the user's own wrapper and is never touched.
|
|
31
|
+
*/
|
|
32
|
+
function isOurs(h, cmd, event) {
|
|
33
|
+
if (!isObject(h) || h['type'] !== 'command' || typeof h['command'] !== 'string')
|
|
34
|
+
return false;
|
|
35
|
+
const c = h['command'];
|
|
36
|
+
return (c === commandFor(cmd, event) ||
|
|
37
|
+
new RegExp(`^(?:\\S*/)?oort hook ${event}$`).test(c) ||
|
|
38
|
+
// A package's bin, as `oort install` writes it: `<prefix>/oort(mem)/dist/main.js`.
|
|
39
|
+
new RegExp(`^(?:'[^']*/oort(?:mem)?/dist/main\\.js'|[^'\\s]*/oort(?:mem)?/dist/main\\.js) hook ${event}$`).test(c));
|
|
40
|
+
}
|
|
41
|
+
function desired(cmd, event) {
|
|
42
|
+
return { type: 'command', command: commandFor(cmd, event), timeout: HOOK_TIMEOUTS[event] };
|
|
43
|
+
}
|
|
44
|
+
function parseSettings(text, file) {
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = text.trim().length === 0 ? {} : JSON.parse(text);
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
throw new Error(`Claude Code settings ${file} is not valid JSON: ${e.message}`);
|
|
51
|
+
}
|
|
52
|
+
if (!isObject(parsed))
|
|
53
|
+
throw new Error(`Claude Code settings ${file} is not a JSON object`);
|
|
54
|
+
const hooks = parsed['hooks'];
|
|
55
|
+
if (hooks === undefined)
|
|
56
|
+
return parsed;
|
|
57
|
+
if (!isObject(hooks))
|
|
58
|
+
throw new Error(`Claude Code settings ${file}: "hooks" is not an object`);
|
|
59
|
+
for (const event of HOOK_EVENTS) {
|
|
60
|
+
if (hooks[event] !== undefined && !Array.isArray(hooks[event])) {
|
|
61
|
+
throw new Error(`Claude Code settings ${file}: "hooks.${event}" is not an array`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
function readSettings(file) {
|
|
67
|
+
let text;
|
|
68
|
+
try {
|
|
69
|
+
text = fs.readFileSync(file, 'utf8');
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
if (e.code === 'ENOENT') {
|
|
73
|
+
return { settings: {}, existed: false, mode: 0o600 };
|
|
74
|
+
}
|
|
75
|
+
throw e;
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
settings: parseSettings(text, file),
|
|
79
|
+
existed: true,
|
|
80
|
+
mode: fs.statSync(file).mode & 0o777,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const backupPath = (file) => `${file}.oort-backup`;
|
|
84
|
+
function writeSettings(file, settings, existed, mode) {
|
|
85
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
86
|
+
const backup = backupPath(file);
|
|
87
|
+
if (existed && !fs.existsSync(backup)) {
|
|
88
|
+
fs.copyFileSync(file, backup, fs.constants.COPYFILE_EXCL);
|
|
89
|
+
if (process.platform !== 'win32')
|
|
90
|
+
fs.chmodSync(backup, 0o600);
|
|
91
|
+
}
|
|
92
|
+
// A settings file symlinked from a dotfiles repo stays a symlink: write its target.
|
|
93
|
+
const target = existed ? fs.realpathSync(file) : file;
|
|
94
|
+
writeFileAtomic(target, `${JSON.stringify(settings, null, 2)}\n`, mode);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Containers (`hooks`, `hooks.<Event>`) that existed before Oort first
|
|
98
|
+
* installed, read from the one-time backup. No backup means the file did not
|
|
99
|
+
* exist then, so every container is ours. An unreadable backup keeps them all.
|
|
100
|
+
*/
|
|
101
|
+
function preexisting(file) {
|
|
102
|
+
let text;
|
|
103
|
+
try {
|
|
104
|
+
text = fs.readFileSync(backupPath(file), 'utf8');
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return { hooks: false, events: new Set() };
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const before = parseSettings(text, backupPath(file));
|
|
111
|
+
const hooks = before['hooks'];
|
|
112
|
+
return {
|
|
113
|
+
hooks: isObject(hooks),
|
|
114
|
+
events: new Set(isObject(hooks) ? Object.keys(hooks) : []),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return { hooks: true, events: new Set(HOOK_EVENTS) };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Merges Oort's three hook entries (`<cmd> hook <Event>`) into the settings
|
|
123
|
+
* file, creating it if missing. Idempotent; replaces a stale Oort entry in
|
|
124
|
+
* place; never removes or reorders anyone else's hooks. Writes atomically and
|
|
125
|
+
* keeps a one-time backup of the original at `<settingsPath>.oort-backup`.
|
|
126
|
+
* Refuses (file untouched) settings that are not JSON, or whose `hooks` or
|
|
127
|
+
* hook event values have the wrong shape.
|
|
128
|
+
*/
|
|
129
|
+
export function installHooks(settingsPath, cmd) {
|
|
130
|
+
const { settings, existed, mode } = readSettings(settingsPath);
|
|
131
|
+
const before = JSON.stringify(settings);
|
|
132
|
+
const hooks = isObject(settings['hooks']) ? { ...settings['hooks'] } : {};
|
|
133
|
+
for (const event of HOOK_EVENTS) {
|
|
134
|
+
const want = desired(cmd, event);
|
|
135
|
+
const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
136
|
+
// Replace the first of ours in place, drop any other copies.
|
|
137
|
+
let placed = false;
|
|
138
|
+
const next = [];
|
|
139
|
+
for (const g of groups) {
|
|
140
|
+
if (!isObject(g) || !Array.isArray(g['hooks'])) {
|
|
141
|
+
next.push(g);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const hs = [];
|
|
145
|
+
let touched = false;
|
|
146
|
+
for (const h of g['hooks']) {
|
|
147
|
+
if (!isOurs(h, cmd, event)) {
|
|
148
|
+
hs.push(h);
|
|
149
|
+
}
|
|
150
|
+
else if (!placed) {
|
|
151
|
+
hs.push(want);
|
|
152
|
+
placed = true;
|
|
153
|
+
touched = true;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
touched = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (!touched)
|
|
160
|
+
next.push(g);
|
|
161
|
+
else if (hs.length > 0)
|
|
162
|
+
next.push({ ...g, hooks: hs });
|
|
163
|
+
}
|
|
164
|
+
if (!placed)
|
|
165
|
+
next.push({ hooks: [want] });
|
|
166
|
+
hooks[event] = next;
|
|
167
|
+
}
|
|
168
|
+
const updated = { ...settings, hooks };
|
|
169
|
+
if (JSON.stringify(updated) === before)
|
|
170
|
+
return { changed: false };
|
|
171
|
+
writeSettings(settingsPath, updated, existed, mode);
|
|
172
|
+
return { changed: true };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Removes only Oort's entries. A group left empty by the removal goes too; an
|
|
176
|
+
* event array or `hooks` object left empty goes only if Oort created it (it is
|
|
177
|
+
* absent from the pre-install backup). A missing file is left missing.
|
|
178
|
+
*/
|
|
179
|
+
export function uninstallHooks(settingsPath, cmd) {
|
|
180
|
+
if (!fs.existsSync(settingsPath))
|
|
181
|
+
return { changed: false };
|
|
182
|
+
const { settings, existed, mode } = readSettings(settingsPath);
|
|
183
|
+
if (!isObject(settings['hooks']))
|
|
184
|
+
return { changed: false };
|
|
185
|
+
const hooks = { ...settings['hooks'] };
|
|
186
|
+
const kept = preexisting(settingsPath);
|
|
187
|
+
let removed = false;
|
|
188
|
+
for (const event of HOOK_EVENTS) {
|
|
189
|
+
const groups = hooks[event];
|
|
190
|
+
if (!Array.isArray(groups))
|
|
191
|
+
continue;
|
|
192
|
+
const next = [];
|
|
193
|
+
for (const g of groups) {
|
|
194
|
+
if (!isObject(g) || !Array.isArray(g['hooks'])) {
|
|
195
|
+
next.push(g);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const hs = g['hooks'];
|
|
199
|
+
const left = hs.filter((h) => !isOurs(h, cmd, event));
|
|
200
|
+
if (left.length === hs.length)
|
|
201
|
+
next.push(g);
|
|
202
|
+
else if (left.length > 0)
|
|
203
|
+
next.push({ ...g, hooks: left });
|
|
204
|
+
}
|
|
205
|
+
if (next.length === groups.length && next.every((g, i) => g === groups[i]))
|
|
206
|
+
continue;
|
|
207
|
+
removed = true;
|
|
208
|
+
if (next.length === 0 && !kept.events.has(event))
|
|
209
|
+
delete hooks[event];
|
|
210
|
+
else
|
|
211
|
+
hooks[event] = next;
|
|
212
|
+
}
|
|
213
|
+
if (!removed)
|
|
214
|
+
return { changed: false };
|
|
215
|
+
const updated = { ...settings, hooks };
|
|
216
|
+
if (Object.keys(hooks).length === 0 && !kept.hooks)
|
|
217
|
+
delete updated['hooks'];
|
|
218
|
+
writeSettings(settingsPath, updated, existed, mode);
|
|
219
|
+
return { changed: true };
|
|
220
|
+
}
|
|
221
|
+
/** The bin part of `<bin> hook <event>`, with shellQuote's quoting undone. */
|
|
222
|
+
function binOf(command, event) {
|
|
223
|
+
const bin = command.slice(0, -` hook ${event}`.length);
|
|
224
|
+
return bin.startsWith("'") && bin.endsWith("'") ? bin.slice(1, -1).replaceAll(`'\\''`, "'") : bin;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Read-only: which of Oort's hooks the settings file holds, for `oort
|
|
228
|
+
* doctor`. Throws, like `installHooks`, on settings that are not JSON or have
|
|
229
|
+
* the wrong shape.
|
|
230
|
+
*/
|
|
231
|
+
export function hookStatus(settingsPath, cmd) {
|
|
232
|
+
const { settings, existed } = readSettings(settingsPath);
|
|
233
|
+
const hooks = isObject(settings['hooks']) ? settings['hooks'] : {};
|
|
234
|
+
const events = HOOK_EVENTS.map((event) => {
|
|
235
|
+
const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
236
|
+
for (const g of groups) {
|
|
237
|
+
if (!isObject(g) || !Array.isArray(g['hooks']))
|
|
238
|
+
continue;
|
|
239
|
+
for (const h of g['hooks']) {
|
|
240
|
+
if (!isOurs(h, cmd ?? 'oort', event))
|
|
241
|
+
continue;
|
|
242
|
+
const command = h.command;
|
|
243
|
+
if (cmd !== undefined && command === commandFor(cmd, event)) {
|
|
244
|
+
return { event, state: 'current' };
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
event,
|
|
248
|
+
state: cmd === undefined ? 'found' : 'stale',
|
|
249
|
+
command: binOf(command, event),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return { event, state: 'missing' };
|
|
254
|
+
});
|
|
255
|
+
return { exists: existed, events };
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=install.js.map
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One JSON line per hook run in `$OORT_HOME/logs/hooks.log`. Entries hold ids,
|
|
3
|
+
* outcomes, counts and timings only, never prompt, memory or transcript text:
|
|
4
|
+
* the logger takes no free-form string beyond the fields below, and callers
|
|
5
|
+
* pass outcome names they choose, never input.
|
|
6
|
+
*/
|
|
7
|
+
export interface HookLogEntry {
|
|
8
|
+
ts: string;
|
|
9
|
+
event: string;
|
|
10
|
+
/** Claude Code's session id (sanitised). */
|
|
11
|
+
session: string | null;
|
|
12
|
+
project: string | null;
|
|
13
|
+
outcome: string;
|
|
14
|
+
counts: Record<string, number>;
|
|
15
|
+
timings: Record<string, number>;
|
|
16
|
+
degraded?: string[];
|
|
17
|
+
/** Queue line ids (random uuids), e.g. for dead-lettered lines. */
|
|
18
|
+
ids?: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface HookLogger {
|
|
21
|
+
/** Appends one line. Never throws: a broken log must not break a session. */
|
|
22
|
+
write(entry: Omit<HookLogEntry, 'ts'> & {
|
|
23
|
+
ts?: string;
|
|
24
|
+
}): void;
|
|
25
|
+
}
|
|
26
|
+
/** The log rotates to `hooks.log.1` (one rotation kept) once it exceeds this. */
|
|
27
|
+
export declare const HOOK_LOG_MAX_BYTES: number;
|
|
28
|
+
export declare function createHookLogger(home: string, opts?: {
|
|
29
|
+
maxBytes?: number;
|
|
30
|
+
}): HookLogger;
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { appendPrivateFile } from '@oortmem/core';
|
|
4
|
+
/** The log rotates to `hooks.log.1` (one rotation kept) once it exceeds this. */
|
|
5
|
+
export const HOOK_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
6
|
+
const ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
7
|
+
const TOKEN_MAX = 64;
|
|
8
|
+
/**
|
|
9
|
+
* Event, outcome and degradation names: short tokens of `[A-Za-z0-9:_-]`,
|
|
10
|
+
* whatever a caller passes, so free text cannot reach the log by construction.
|
|
11
|
+
*/
|
|
12
|
+
function token(v) {
|
|
13
|
+
const t = v.replace(/[^A-Za-z0-9:_-]+/g, '_').slice(0, TOKEN_MAX);
|
|
14
|
+
return t.length > 0 ? t : '_';
|
|
15
|
+
}
|
|
16
|
+
/** Ids and outcome names are ours, but are clamped to a safe charset anyway. */
|
|
17
|
+
function safeId(v) {
|
|
18
|
+
if (v === null)
|
|
19
|
+
return null;
|
|
20
|
+
return ID.test(v) ? v : '[invalid]';
|
|
21
|
+
}
|
|
22
|
+
function numbers(r) {
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const [k, v] of Object.entries(r))
|
|
25
|
+
if (typeof v === 'number')
|
|
26
|
+
out[k] = Math.round(v * 10) / 10;
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
export function createHookLogger(home, opts = {}) {
|
|
30
|
+
const file = path.join(home, 'logs', 'hooks.log');
|
|
31
|
+
const maxBytes = opts.maxBytes ?? HOOK_LOG_MAX_BYTES;
|
|
32
|
+
return {
|
|
33
|
+
write(entry) {
|
|
34
|
+
try {
|
|
35
|
+
const line = {
|
|
36
|
+
ts: entry.ts ?? new Date().toISOString(),
|
|
37
|
+
event: token(entry.event),
|
|
38
|
+
session: safeId(entry.session),
|
|
39
|
+
project: safeId(entry.project),
|
|
40
|
+
outcome: token(entry.outcome),
|
|
41
|
+
counts: numbers(entry.counts),
|
|
42
|
+
timings: numbers(entry.timings),
|
|
43
|
+
...(entry.degraded !== undefined && entry.degraded.length > 0
|
|
44
|
+
? { degraded: entry.degraded.map(token) }
|
|
45
|
+
: {}),
|
|
46
|
+
...(entry.ids !== undefined && entry.ids.length > 0
|
|
47
|
+
? { ids: entry.ids.map((id) => safeId(id) ?? '[invalid]') }
|
|
48
|
+
: {}),
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
if (fs.statSync(file).size > maxBytes)
|
|
52
|
+
fs.renameSync(file, `${file}.1`);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// No log yet, or a concurrent hook rotated it first.
|
|
56
|
+
}
|
|
57
|
+
appendPrivateFile(file, `${JSON.stringify(line)}\n`);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Logging is best effort.
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=log.js.map
|
package/dist/queue.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type Observation, type Scope } from '@oortmem/core';
|
|
3
|
+
import type { HookDeps } from './deps.js';
|
|
4
|
+
/**
|
|
5
|
+
* The write-path queue between the Stop hook and the drain.
|
|
6
|
+
*
|
|
7
|
+
* - `$OORT_HOME/queue/<session>.jsonl`: one JSON line per scrubbed observation,
|
|
8
|
+
* appended by the Stop hook (0600, directory 0700).
|
|
9
|
+
* - `$OORT_HOME/queue/.lock`: the drain lock (`wx`); one drain at a time. A lock
|
|
10
|
+
* older than ten minutes is from a crashed drain and is broken.
|
|
11
|
+
* - `<file>.lock`: a short per-file lock held by an append, and by the drain
|
|
12
|
+
* while it rewrites the file, so no concurrent append is lost.
|
|
13
|
+
* - `$OORT_HOME/queue/dead/<session>.jsonl`: lines that failed three times, or
|
|
14
|
+
* could not be parsed.
|
|
15
|
+
*/
|
|
16
|
+
export declare const DRAIN_LOCK_STALE_MS: number;
|
|
17
|
+
export declare const DRAIN_MAX_PER_RUN = 50;
|
|
18
|
+
export declare const MAX_ATTEMPTS = 3;
|
|
19
|
+
declare const QueueLineSchema: z.ZodObject<{
|
|
20
|
+
v: z.ZodLiteral<1>;
|
|
21
|
+
id: z.ZodString;
|
|
22
|
+
scope: z.ZodObject<{
|
|
23
|
+
orgId: z.ZodString;
|
|
24
|
+
customerId: z.ZodString;
|
|
25
|
+
agentId: z.ZodString;
|
|
26
|
+
projectId: z.ZodOptional<z.ZodString>;
|
|
27
|
+
}, z.core.$strip>;
|
|
28
|
+
obs: z.ZodObject<{
|
|
29
|
+
userText: z.ZodString;
|
|
30
|
+
assistantText: z.ZodString;
|
|
31
|
+
toolSummaries: z.ZodArray<z.ZodString>;
|
|
32
|
+
sessionId: z.ZodOptional<z.ZodString>;
|
|
33
|
+
turnRef: z.ZodOptional<z.ZodString>;
|
|
34
|
+
projectId: z.ZodOptional<z.ZodString>;
|
|
35
|
+
at: z.ZodString;
|
|
36
|
+
}, z.core.$strip>;
|
|
37
|
+
attempts: z.ZodNumber;
|
|
38
|
+
queuedAt: z.ZodString;
|
|
39
|
+
}, z.core.$strip>;
|
|
40
|
+
export type QueueLine = z.infer<typeof QueueLineSchema>;
|
|
41
|
+
export declare const queueDir: (home: string) => string;
|
|
42
|
+
/** A file-name-safe form of a session id; anything unusual is hashed. */
|
|
43
|
+
export declare function queueName(session: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* Scrubs and appends observations to the session's queue file; observations
|
|
46
|
+
* that are mostly secret are dropped. Never holds a lock across anything but
|
|
47
|
+
* the append; if the file lock is busy, the batch goes to a unique segment
|
|
48
|
+
* file instead (the drain reads every `*.jsonl`), so nothing is lost.
|
|
49
|
+
*/
|
|
50
|
+
export declare function enqueueObservations(home: string, session: string, scope: Scope, observations: Observation[], now?: Date): {
|
|
51
|
+
queued: number;
|
|
52
|
+
dropped: number;
|
|
53
|
+
};
|
|
54
|
+
/** True when some queue file is waiting to be drained. */
|
|
55
|
+
export declare function hasPendingQueue(home: string): boolean;
|
|
56
|
+
export interface DrainResult {
|
|
57
|
+
processed: number;
|
|
58
|
+
failed: number;
|
|
59
|
+
remaining: number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A stable id for a queued turn: `<sessionId>:<transcript turn>` when both are known, else the
|
|
63
|
+
* queue line's own id (kept across retries, since a failed line stays in its file).
|
|
64
|
+
*/
|
|
65
|
+
export declare function turnRefOf(line: {
|
|
66
|
+
id: string;
|
|
67
|
+
obs: {
|
|
68
|
+
sessionId?: string;
|
|
69
|
+
turnRef?: string;
|
|
70
|
+
};
|
|
71
|
+
}): string;
|
|
72
|
+
/**
|
|
73
|
+
* Observes queued lines, oldest file first, at most 50 per run, under the
|
|
74
|
+
* drain lock. A line that observed is removed; one whose observe threw keeps
|
|
75
|
+
* its place with `attempts + 1`, and moves to `dead/` at the third failure.
|
|
76
|
+
* Without embed, decide and write adapters the queue is left untouched.
|
|
77
|
+
*/
|
|
78
|
+
export declare function drainQueue(deps: HookDeps): Promise<DrainResult>;
|
|
79
|
+
export {};
|