dist / tools / callJournal.js
dist / tools / callJournal.js
"use strict";
/**
* @file callJournal.ts
* B1: append-only JSONL journal of tool calls — METADATA ONLY, never content.
*
* One line per call: { ts, tool, paramsHash, ms, status, [code], [count] }.
* status: ok | error | loop_blocked | streak_blocked | denied.
* Paths/params are one-way hashed (sha1, 12 hex) — the journal cannot leak
* file contents or prompts, only shapes of activity.
*
* Contract (hard): record() is fire-and-forget and NEVER rejects — logging
* must never break a chat turn (Constitution §3.1 resilience by design).
* Rotates at maxBytes keeping one generation (<file>.1).
*
* Module-scoped singleton (like loopGuard trackers): the SDK re-invokes the
* tools provider per turn; counters and the write queue must survive that.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.CallJournal = exports.callJournal = void 0;
exports.hashParams = hashParams;
const fs_1 = require("fs/promises");
const path_1 = require("path");
const os_1 = require("os");
const crypto_1 = require("crypto");
const DEFAULT_PATH = (0, path_1.join)((0, os_1.homedir)(), ".maestro-toolbox", "logs", "tool-calls.jsonl");
const DEFAULT_MAX_BYTES = 2_000_000;
/** One-way hash of tool+params for privacy-preserving correlation. */
function hashParams(s) {
return crypto_1.createHash("sha1").update(String(s)).digest("hex").slice(0, 12);
}
class CallJournal {
constructor(filePath = DEFAULT_PATH, maxBytes = DEFAULT_MAX_BYTES) {
this.path = filePath;
this.maxBytes = maxBytes;
this.bytes = -1; // -1 = unknown, stat'd lazily on first append
this._queue = Promise.resolve();
}
/** Redirect the journal (used by tests; production keeps DEFAULT_PATH). */
setPath(p) {
this.path = p;
this.bytes = -1;
}
/** Queue one entry. Never throws; returns the chain for tests' flush(). */
record(entry) {
try {
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n";
this._queue = this._queue.then(() => this._append(line)).catch(() => { });
return this._queue;
}
catch {
return Promise.resolve();
}
}
/** Await all queued writes (test helper). */
async flush() {
let p = this._queue;
await p;
while (this._queue !== p) {
p = this._queue;
await p;
}
}
async _append(line) {
await (0, fs_1.mkdir)((0, path_1.dirname)(this.path), { recursive: true });
if (this.bytes < 0) {
try {
this.bytes = (await (0, fs_1.stat)(this.path)).size;
}
catch {
this.bytes = 0;
}
}
if (this.bytes + line.length > this.maxBytes) {
// keep one rotated generation; rename silently fails on broken fs
await (0, fs_1.rm)(this.path + ".1", { force: true }).catch(() => { });
await (0, fs_1.rename)(this.path, this.path + ".1").catch(() => { });
this.bytes = 0;
}
await (0, fs_1.appendFile)(this.path, line, "utf8");
this.bytes += line.length;
}
}
exports.CallJournal = CallJournal;
/** Shared instance used by loopGuard. */
exports.callJournal = new CallJournal();
//# sourceMappingURL=callJournal.js.map
"use strict";
/**
* @file callJournal.ts
* B1: append-only JSONL journal of tool calls — METADATA ONLY, never content.
*
* One line per call: { ts, tool, paramsHash, ms, status, [code], [count] }.
* status: ok | error | loop_blocked | streak_blocked | denied.
* Paths/params are one-way hashed (sha1, 12 hex) — the journal cannot leak
* file contents or prompts, only shapes of activity.
*
* Contract (hard): record() is fire-and-forget and NEVER rejects — logging
* must never break a chat turn (Constitution §3.1 resilience by design).
* Rotates at maxBytes keeping one generation (<file>.1).
*
* Module-scoped singleton (like loopGuard trackers): the SDK re-invokes the
* tools provider per turn; counters and the write queue must survive that.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.CallJournal = exports.callJournal = void 0;
exports.hashParams = hashParams;
const fs_1 = require("fs/promises");
const path_1 = require("path");
const os_1 = require("os");
const crypto_1 = require("crypto");
const DEFAULT_PATH = (0, path_1.join)((0, os_1.homedir)(), ".maestro-toolbox", "logs", "tool-calls.jsonl");
const DEFAULT_MAX_BYTES = 2_000_000;
/** One-way hash of tool+params for privacy-preserving correlation. */
function hashParams(s) {
return crypto_1.createHash("sha1").update(String(s)).digest("hex").slice(0, 12);
}
class CallJournal {
constructor(filePath = DEFAULT_PATH, maxBytes = DEFAULT_MAX_BYTES) {
this.path = filePath;
this.maxBytes = maxBytes;
this.bytes = -1; // -1 = unknown, stat'd lazily on first append
this._queue = Promise.resolve();
}
/** Redirect the journal (used by tests; production keeps DEFAULT_PATH). */
setPath(p) {
this.path = p;
this.bytes = -1;
}
/** Queue one entry. Never throws; returns the chain for tests' flush(). */
record(entry) {
try {
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n";
this._queue = this._queue.then(() => this._append(line)).catch(() => { });
return this._queue;
}
catch {
return Promise.resolve();
}
}
/** Await all queued writes (test helper). */
async flush() {
let p = this._queue;
await p;
while (this._queue !== p) {
p = this._queue;
await p;
}
}
async _append(line) {
await (0, fs_1.mkdir)((0, path_1.dirname)(this.path), { recursive: true });
if (this.bytes < 0) {
try {
this.bytes = (await (0, fs_1.stat)(this.path)).size;
}
catch {
this.bytes = 0;
}
}
if (this.bytes + line.length > this.maxBytes) {
// keep one rotated generation; rename silently fails on broken fs
await (0, fs_1.rm)(this.path + ".1", { force: true }).catch(() => { });
await (0, fs_1.rename)(this.path, this.path + ".1").catch(() => { });
this.bytes = 0;
}
await (0, fs_1.appendFile)(this.path, line, "utf8");
this.bytes += line.length;
}
}
exports.CallJournal = CallJournal;
/** Shared instance used by loopGuard. */
exports.callJournal = new CallJournal();
//# sourceMappingURL=callJournal.js.map