@mmerterden/multi-agent-pipeline 20.29.0 → 20.33.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/CHANGELOG.md +86 -0
- package/README.md +50 -5
- package/README.tr.md +64 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +9 -9
- package/install/_unattended-profile.mjs +61 -0
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +19 -3
- package/install/codex.mjs +2 -0
- package/install/copilot.mjs +13 -0
- package/install/index.mjs +10 -1
- package/install/templates/claude-hooks.json +12 -1
- package/manifest.json +230 -88
- package/package.json +3 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +4 -4
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
- package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
- package/pipeline/commands/multi-agent/office-on/SKILL.md +51 -0
- package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
- package/pipeline/commands/multi-agent/prune-logs/SKILL.md +4 -1
- package/pipeline/commands/multi-agent/review/SKILL.md +4 -3
- package/pipeline/commands/multi-agent/scenario-audit/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
- package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
- package/pipeline/commands/multi-agent/sync/SKILL.md +15 -3
- package/pipeline/commands/multi-agent/test-gen/SKILL.md +29 -3
- package/pipeline/contract/CHANGELOG.md +108 -0
- package/pipeline/contract/README.md +72 -12
- package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
- package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
- package/pipeline/contract/fixtures/error-unsigned.json +1 -1
- package/pipeline/contract/fixtures/launch-plan.json +1 -1
- package/pipeline/contract/fixtures/runs-awaiting-question.json +6 -1
- package/pipeline/contract/fixtures/runs-empty.json +1 -1
- package/pipeline/contract/fixtures/runs-failed.json +6 -1
- package/pipeline/contract/fixtures/runs-old-schema.json +6 -1
- package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +5 -1
- package/pipeline/contract/fixtures/runs-pr-opened.json +6 -1
- package/pipeline/contract/fixtures/runs-running.json +6 -1
- package/pipeline/contract/manifest.json +506 -27
- package/pipeline/contract/types/index.d.ts +829 -4
- package/pipeline/lib/bg-launch.mjs +106 -0
- package/pipeline/lib/code-digest.mjs +49 -0
- package/pipeline/lib/credential-resolve.mjs +42 -2
- package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
- package/pipeline/multi-agent-refs/features/office-threat-model.md +109 -0
- package/pipeline/multi-agent-refs/features/unattended-security.md +1 -1
- package/pipeline/multi-agent-refs/help/en.md +3 -0
- package/pipeline/multi-agent-refs/help/tr.md +3 -0
- package/pipeline/multi-agent-refs/phases/modes.md +7 -7
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +16 -10
- package/pipeline/multi-agent-refs/review-standalone-tracker.md +37 -0
- package/pipeline/multi-agent-refs/tracker-contract.md +8 -3
- package/pipeline/office/NOTICE.md +45 -0
- package/pipeline/office/assets/ARCHIVE_BOXES.png +0 -0
- package/pipeline/office/assets/BIN.png +0 -0
- package/pipeline/office/assets/BOOKSHELF.png +0 -0
- package/pipeline/office/assets/CACTUS.png +0 -0
- package/pipeline/office/assets/CLOCK.png +0 -0
- package/pipeline/office/assets/COFFEE.png +0 -0
- package/pipeline/office/assets/COFFEE_MACHINE.png +0 -0
- package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
- package/pipeline/office/assets/COUNTER_SINK.png +0 -0
- package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
- package/pipeline/office/assets/DESK_FRONT.png +0 -0
- package/pipeline/office/assets/DESK_SIDE.png +0 -0
- package/pipeline/office/assets/DEVICE_STAND.png +0 -0
- package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
- package/pipeline/office/assets/FILING_CABINET.png +0 -0
- package/pipeline/office/assets/FRIDGE.png +0 -0
- package/pipeline/office/assets/HANGING_PLANT.png +0 -0
- package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
- package/pipeline/office/assets/LARGE_PLANT.png +0 -0
- package/pipeline/office/assets/MAILBOX.png +0 -0
- package/pipeline/office/assets/MOP_BUCKET.png +0 -0
- package/pipeline/office/assets/PC_BACK.png +0 -0
- package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
- package/pipeline/office/assets/PC_SIDE.png +0 -0
- package/pipeline/office/assets/PLANT.png +0 -0
- package/pipeline/office/assets/PLANT_2.png +0 -0
- package/pipeline/office/assets/POT.png +0 -0
- package/pipeline/office/assets/PRINTER.png +0 -0
- package/pipeline/office/assets/RECEPTION_DESK.png +0 -0
- package/pipeline/office/assets/SERVER_RACK.png +0 -0
- package/pipeline/office/assets/SERVER_RACK_2.png +0 -0
- package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
- package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
- package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
- package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
- package/pipeline/office/assets/SOFA_BACK.png +0 -0
- package/pipeline/office/assets/SOFA_FRONT.png +0 -0
- package/pipeline/office/assets/SOFA_SIDE.png +0 -0
- package/pipeline/office/assets/TABLE_FRONT.png +0 -0
- package/pipeline/office/assets/TOILET_STALL.png +0 -0
- package/pipeline/office/assets/VENDING.png +0 -0
- package/pipeline/office/assets/WASHBASIN.png +0 -0
- package/pipeline/office/assets/WATER_COOLER.png +0 -0
- package/pipeline/office/assets/WHITEBOARD.png +0 -0
- package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
- package/pipeline/office/assets/char_0.png +0 -0
- package/pipeline/office/assets/char_1.png +0 -0
- package/pipeline/office/assets/char_2.png +0 -0
- package/pipeline/office/assets/char_3.png +0 -0
- package/pipeline/office/assets/char_4.png +0 -0
- package/pipeline/office/assets/char_5.png +0 -0
- package/pipeline/office/assets/floor_0.png +0 -0
- package/pipeline/office/assets/floor_1.png +0 -0
- package/pipeline/office/assets/floor_2.png +0 -0
- package/pipeline/office/assets/floor_3.png +0 -0
- package/pipeline/office/assets/floor_4.png +0 -0
- package/pipeline/office/assets/floor_5.png +0 -0
- package/pipeline/office/assets/floor_6.png +0 -0
- package/pipeline/office/assets/floor_7.png +0 -0
- package/pipeline/office/assets/floor_8.png +0 -0
- package/pipeline/office/assets/wall_0.png +0 -0
- package/pipeline/office/index.html +8431 -0
- package/pipeline/office/toolkit.json +3 -0
- package/pipeline/schemas/activity-chat.schema.json +78 -0
- package/pipeline/schemas/activity-roles.json +48 -0
- package/pipeline/schemas/activity-roles.schema.json +49 -0
- package/pipeline/schemas/activity.schema.json +613 -0
- package/pipeline/schemas/agent-state.schema.json +493 -115
- package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
- package/pipeline/schemas/autopilot-on.schema.json +111 -0
- package/pipeline/schemas/contract-error.schema.json +45 -4
- package/pipeline/schemas/issue-maturity.schema.json +68 -0
- package/pipeline/schemas/job-change.schema.json +72 -0
- package/pipeline/schemas/job-request.schema.json +38 -0
- package/pipeline/schemas/launch.json +4 -4
- package/pipeline/schemas/ledger.schema.json +202 -0
- package/pipeline/schemas/office-credentials.schema.json +71 -0
- package/pipeline/schemas/office-jobs.json +253 -0
- package/pipeline/schemas/office-jobs.schema.json +62 -0
- package/pipeline/schemas/office-memory.schema.json +105 -0
- package/pipeline/schemas/office-settings-request.schema.json +30 -0
- package/pipeline/schemas/office-settings.schema.json +67 -0
- package/pipeline/schemas/permissions.schema.json +47 -0
- package/pipeline/schemas/phases.json +10 -2
- package/pipeline/schemas/phases.schema.json +86 -19
- package/pipeline/schemas/prefs.schema.json +19 -0
- package/pipeline/schemas/prs.schema.json +78 -0
- package/pipeline/schemas/prune-logs-request.schema.json +26 -0
- package/pipeline/schemas/prune-logs.schema.json +52 -0
- package/pipeline/schemas/repo-change.schema.json +54 -0
- package/pipeline/schemas/repo-request.schema.json +29 -0
- package/pipeline/schemas/repos.schema.json +49 -1
- package/pipeline/schemas/review-verdict-request.schema.json +20 -0
- package/pipeline/schemas/review-verdict.schema.json +62 -0
- package/pipeline/schemas/routine-delete.schema.json +59 -0
- package/pipeline/schemas/routines.schema.json +38 -0
- package/pipeline/schemas/run-evidence.schema.json +29 -0
- package/pipeline/schemas/run-results.schema.json +218 -0
- package/pipeline/schemas/runs-index.schema.json +257 -46
- package/pipeline/schemas/service-read.schema.json +58 -0
- package/pipeline/schemas/stack-change.schema.json +82 -0
- package/pipeline/schemas/stack-request.schema.json +33 -0
- package/pipeline/schemas/stack.schema.json +59 -0
- package/pipeline/schemas/steer-request.schema.json +25 -0
- package/pipeline/schemas/steer.schema.json +59 -0
- package/pipeline/schemas/system-status.schema.json +111 -0
- package/pipeline/schemas/tool-call-request.schema.json +19 -0
- package/pipeline/schemas/tool-call.schema.json +47 -0
- package/pipeline/schemas/tools.schema.json +65 -0
- package/pipeline/schemas/unattended-profile.json +7 -2
- package/pipeline/scripts/_active-run.mjs +2 -1
- package/pipeline/scripts/_cost.mjs +66 -1
- package/pipeline/scripts/_office-smoke-lib.sh +115 -0
- package/pipeline/scripts/_spawn-async.mjs +77 -0
- package/pipeline/scripts/_transcript-tail.mjs +274 -0
- package/pipeline/scripts/activity-snapshot.mjs +1223 -0
- package/pipeline/scripts/autopilot-control.mjs +214 -9
- package/pipeline/scripts/autopilot-runner.mjs +7 -5
- package/pipeline/scripts/contract-server.mjs +1853 -197
- package/pipeline/scripts/cost-analyze.mjs +2 -53
- package/pipeline/scripts/cost-table.json +1 -0
- package/pipeline/scripts/gen-mode-dispatch.mjs +3 -1
- package/pipeline/scripts/job-launch.mjs +477 -0
- package/pipeline/scripts/launch-request.mjs +94 -9
- package/pipeline/scripts/office-build.mjs +226 -0
- package/pipeline/scripts/office-control.mjs +410 -0
- package/pipeline/scripts/office-credentials.mjs +106 -0
- package/pipeline/scripts/office-ledger.mjs +409 -0
- package/pipeline/scripts/office-memory.mjs +200 -0
- package/pipeline/scripts/office-prune.mjs +125 -0
- package/pipeline/scripts/office-settings.mjs +145 -0
- package/pipeline/scripts/phase-tracker.sh +12 -5
- package/pipeline/scripts/prune-logs.sh +53 -2
- package/pipeline/scripts/routine-registry.mjs +42 -6
- package/pipeline/scripts/run-results.mjs +373 -0
- package/pipeline/scripts/runs-index.mjs +62 -2
- package/pipeline/scripts/service-read.mjs +244 -0
- package/pipeline/scripts/stack-toggle.mjs +285 -0
- package/pipeline/scripts/steer-run.mjs +97 -0
- package/pipeline/scripts/system-status.mjs +196 -0
- package/pipeline/scripts/task-mirror.mjs +150 -0
- package/pipeline/scripts/task-mirror.sh +20 -0
- package/pipeline/scripts/toolkit-tools.mjs +625 -0
- package/pipeline/scripts/transcript-index.mjs +4 -1
- package/pipeline/scripts/uninstall.mjs +3 -0
- package/pipeline/scripts/work-items.mjs +473 -0
- package/pipeline/skills/.skill-manifest.json +23 -11
- package/pipeline/skills/.skills-index.json +34 -1
- package/pipeline/skills/shared/README.md +6 -3
- package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
- package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
- package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +21 -0
- package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
- package/pipeline/skills/shared/core/multi-agent-prune-logs/SKILL.md +4 -1
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +19 -0
- package/pipeline/skills/shared/core/multi-agent-scenario-audit/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
- package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +26 -3
- package/pipeline/skills/skills-index.md +4 -1
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file office-ledger.mjs - what the agents on this machine cost: tokens and
|
|
4
|
+
* estimated USD for today, this week (from Monday) and this month, in local
|
|
5
|
+
* time, by run, repository, model and phase.
|
|
6
|
+
*
|
|
7
|
+
* Two sources, never added together for one run:
|
|
8
|
+
*
|
|
9
|
+
* tracker-state the pipeline's own ledger, `tokens_in/out/cached` per phase
|
|
10
|
+
* in a run's tracker-state.json, priced at the phase's rung;
|
|
11
|
+
* transcript the host's usage records (`message.usage` on each assistant
|
|
12
|
+
* record, counted once per message id) in
|
|
13
|
+
* ~/.claude/projects/<slug>/<session>.jsonl and its
|
|
14
|
+
* subagents/agent-*.jsonl, priced at the model id.
|
|
15
|
+
*
|
|
16
|
+
* A run whose tracker carries tokens is counted from the tracker and its
|
|
17
|
+
* transcripts are left out, so nothing is counted twice. A run without tracker
|
|
18
|
+
* tokens is counted from the transcripts tied to it (by the session id it
|
|
19
|
+
* recorded, else by working directory and branch, as activity-snapshot.mjs
|
|
20
|
+
* ties them). A run with neither is `not-measured`: its numbers are null and
|
|
21
|
+
* it is listed by task id, never folded in as 0. A session no run claims is
|
|
22
|
+
* counted apart as outside the pipeline.
|
|
23
|
+
*
|
|
24
|
+
* Prices come from cost-table.json (_cost.mjs does the arithmetic, a cache
|
|
25
|
+
* write at 1.25 times input). `verifiedAt` in the table dates them; past
|
|
26
|
+
* PRICE_STALE_DAYS the document carries a warning naming that date. These are
|
|
27
|
+
* estimates at list price, not an invoice.
|
|
28
|
+
*
|
|
29
|
+
* Read-only. Each transcript's usage rows are kept in memory keyed by the
|
|
30
|
+
* file's size and mtime, so a second call re-reads only files that changed.
|
|
31
|
+
*
|
|
32
|
+
* Usage:
|
|
33
|
+
* office-ledger.mjs [--json]
|
|
34
|
+
*
|
|
35
|
+
* TRANSCRIPTS_ROOT overrides ~/.claude/projects.
|
|
36
|
+
*
|
|
37
|
+
* Exit: 0 printed, 2 usage.
|
|
38
|
+
*
|
|
39
|
+
* @module pipeline/scripts/office-ledger
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
43
|
+
import { join } from "node:path";
|
|
44
|
+
import { costUsd, modelRateLookup, priceUsage } from "./_cost.mjs";
|
|
45
|
+
import { readJsonl } from "./_jsonl.mjs";
|
|
46
|
+
import { matchRun, modelLabel, phaseAt, repoName, runExtras } from "./activity-snapshot.mjs";
|
|
47
|
+
import { defaultProjectsRoot } from "./transcript-index.mjs";
|
|
48
|
+
import { buildIndex } from "./runs-index.mjs";
|
|
49
|
+
import { readRegularJson } from "../lib/regular-file.mjs";
|
|
50
|
+
import { runMain } from "../lib/fatal.mjs";
|
|
51
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
52
|
+
|
|
53
|
+
export const CONTRACT_VERSION = "1.0.0";
|
|
54
|
+
export const PRICE_STALE_DAYS = 60;
|
|
55
|
+
const OUTSIDE = "outside the pipeline";
|
|
56
|
+
const UNKNOWN = "unknown";
|
|
57
|
+
const TABLE = JSON.parse(readFileSync(new URL("./cost-table.json", import.meta.url), "utf8"));
|
|
58
|
+
const rateFor = modelRateLookup(TABLE);
|
|
59
|
+
const PHASE_NAMES = Object.fromEntries(
|
|
60
|
+
JSON.parse(readFileSync(new URL("../schemas/phases.json", import.meta.url), "utf8")).phases.map(
|
|
61
|
+
(p) => [String(p.id), p.name],
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How old the price table is. Stale past PRICE_STALE_DAYS, and stale when it
|
|
67
|
+
* carries no date at all, since nothing then says the prices were checked.
|
|
68
|
+
*/
|
|
69
|
+
export function priceAge(verifiedAt, now = Date.now()) {
|
|
70
|
+
const t = Date.parse(verifiedAt ?? "");
|
|
71
|
+
if (!Number.isFinite(t)) {
|
|
72
|
+
return {
|
|
73
|
+
ageDays: null,
|
|
74
|
+
stale: true,
|
|
75
|
+
warning: "cost-table.json carries no verifiedAt date; the prices may be out of date",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const ageDays = Math.floor((now - t) / 86_400_000);
|
|
79
|
+
const stale = ageDays > PRICE_STALE_DAYS;
|
|
80
|
+
return {
|
|
81
|
+
ageDays,
|
|
82
|
+
stale,
|
|
83
|
+
warning: stale
|
|
84
|
+
? `cost-table.json prices were last verified ${verifiedAt} (${ageDays} days ago); the estimates may be off`
|
|
85
|
+
: null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Local-time starts of today, this week (Monday) and this month. */
|
|
90
|
+
export function windowStarts(now) {
|
|
91
|
+
const d = new Date(now);
|
|
92
|
+
const today = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
93
|
+
const dow = (d.getDay() + 6) % 7;
|
|
94
|
+
const week = new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow).getTime();
|
|
95
|
+
const month = new Date(d.getFullYear(), d.getMonth(), 1).getTime();
|
|
96
|
+
return { today, week, month };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Transcript usage rows
|
|
101
|
+
|
|
102
|
+
const fileCache = new Map();
|
|
103
|
+
|
|
104
|
+
const listDir = (dir) => {
|
|
105
|
+
try {
|
|
106
|
+
return readdirSync(dir);
|
|
107
|
+
} catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Every session file and its subagent files changed since `since`. */
|
|
113
|
+
function transcriptFiles(projectsRoot, since) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const slug of listDir(projectsRoot)) {
|
|
116
|
+
const dir = join(projectsRoot, slug);
|
|
117
|
+
for (const f of listDir(dir)) {
|
|
118
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
119
|
+
const sessionId = f.slice(0, -6);
|
|
120
|
+
const files = [join(dir, f)];
|
|
121
|
+
const sub = join(dir, sessionId, "subagents");
|
|
122
|
+
for (const s of listDir(sub)) if (/^agent-.*\.jsonl$/.test(s)) files.push(join(sub, s));
|
|
123
|
+
for (const path of files) {
|
|
124
|
+
let st;
|
|
125
|
+
try {
|
|
126
|
+
st = statSync(path);
|
|
127
|
+
} catch {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (st.isFile() && st.mtimeMs >= since)
|
|
131
|
+
out.push({ path, sessionId, size: st.size, mtime: st.mtimeMs });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The usage rows of one file, and the working directory and branch it ran in. */
|
|
139
|
+
async function rowsOf(file) {
|
|
140
|
+
const hit = fileCache.get(file.path);
|
|
141
|
+
if (hit && hit.size === file.size && hit.mtime === file.mtime) return hit;
|
|
142
|
+
const rows = [];
|
|
143
|
+
let cwd = null;
|
|
144
|
+
let branch = null;
|
|
145
|
+
for await (const row of readJsonl(file.path, { maxBytes: Infinity })) {
|
|
146
|
+
// Only assistant records carry usage; every other type (api-request
|
|
147
|
+
// records included) is left as soon as its type is known.
|
|
148
|
+
if (!row.record) continue;
|
|
149
|
+
const rec = row.record;
|
|
150
|
+
if (rec.type !== "assistant") {
|
|
151
|
+
if (!cwd && typeof rec.cwd === "string") cwd = rec.cwd;
|
|
152
|
+
if (!branch && typeof rec.gitBranch === "string") branch = rec.gitBranch;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!cwd && typeof rec.cwd === "string") cwd = rec.cwd;
|
|
156
|
+
if (!branch && typeof rec.gitBranch === "string") branch = rec.gitBranch;
|
|
157
|
+
const u = rec.message?.usage;
|
|
158
|
+
const ts = Date.parse(rec.timestamp ?? "");
|
|
159
|
+
if (!u || !Number.isFinite(ts)) continue;
|
|
160
|
+
rows.push({
|
|
161
|
+
id: typeof rec.message?.id === "string" ? rec.message.id : null,
|
|
162
|
+
ts,
|
|
163
|
+
model: typeof rec.message?.model === "string" ? rec.message.model : null,
|
|
164
|
+
usage: {
|
|
165
|
+
input_tokens: Number(u.input_tokens || 0),
|
|
166
|
+
output_tokens: Number(u.output_tokens || 0),
|
|
167
|
+
cache_read_input_tokens: Number(u.cache_read_input_tokens || 0),
|
|
168
|
+
cache_creation_input_tokens: Number(u.cache_creation_input_tokens || 0),
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
const entry = { size: file.size, mtime: file.mtime, rows, cwd, branch };
|
|
173
|
+
fileCache.set(file.path, entry);
|
|
174
|
+
return entry;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Amounts
|
|
179
|
+
|
|
180
|
+
const zeroTokens = () => ({ in: 0, out: 0, cacheRead: 0, cacheWrite: 0 });
|
|
181
|
+
|
|
182
|
+
function newAmount() {
|
|
183
|
+
return { tokens: null, usd: null, sources: new Set(), unpriced: 0, measured: false };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function addTo(a, entry) {
|
|
187
|
+
a.measured = true;
|
|
188
|
+
a.sources.add(entry.source);
|
|
189
|
+
a.tokens ??= zeroTokens();
|
|
190
|
+
for (const k of ["in", "out", "cacheRead", "cacheWrite"]) a.tokens[k] += entry.tokens[k];
|
|
191
|
+
if (entry.usd === null) a.unpriced += 1;
|
|
192
|
+
else a.usd = (a.usd ?? 0) + entry.usd;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function finish(a, { measuredEmpty = false } = {}) {
|
|
196
|
+
if (!a.measured) {
|
|
197
|
+
return measuredEmpty
|
|
198
|
+
? { tokens: zeroTokens(), usd: 0, source: "transcript", unpriced: 0 }
|
|
199
|
+
: { tokens: null, usd: null, source: "not-measured", unpriced: 0 };
|
|
200
|
+
}
|
|
201
|
+
const source = a.sources.size > 1 ? "mixed" : [...a.sources][0];
|
|
202
|
+
const usd = a.usd === null ? null : Number(a.usd.toFixed(6));
|
|
203
|
+
return { tokens: a.tokens, usd, source, unpriced: a.unpriced };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function readTracker(run) {
|
|
207
|
+
if (!run.dir) return null;
|
|
208
|
+
for (const p of [
|
|
209
|
+
join(run.dir, "tracker-state.json"),
|
|
210
|
+
join(run.dir, "artifacts", "tracker-state.json"),
|
|
211
|
+
]) {
|
|
212
|
+
if (existsSync(p)) return readRegularJson(p, null);
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** One priced entry per tracker phase that carries tokens. */
|
|
218
|
+
function trackerEntries(run, repo) {
|
|
219
|
+
const tracker = readTracker(run);
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const p of Array.isArray(tracker?.phases) ? tracker.phases : []) {
|
|
222
|
+
const tin = Math.round(Number(p.tokens_in ?? 0) || 0);
|
|
223
|
+
const tout = Math.round(Number(p.tokens_out ?? 0) || 0);
|
|
224
|
+
const tc = Math.round(Number(p.tokens_cached ?? 0) || 0);
|
|
225
|
+
if (!tin && !tout && !tc) continue;
|
|
226
|
+
const at =
|
|
227
|
+
Date.parse(p.completed_at ?? "") ||
|
|
228
|
+
Date.parse(p.started_at ?? "") ||
|
|
229
|
+
Date.parse(tracker.started_at ?? "") ||
|
|
230
|
+
null;
|
|
231
|
+
const usd = costUsd(rateFor(p.model), tin, tout, tc);
|
|
232
|
+
out.push({
|
|
233
|
+
source: "tracker-state",
|
|
234
|
+
run: run.taskId,
|
|
235
|
+
repo,
|
|
236
|
+
model: modelLabel(p.model) ?? UNKNOWN,
|
|
237
|
+
phase: p.name || PHASE_NAMES[String(p.id)] || UNKNOWN,
|
|
238
|
+
at,
|
|
239
|
+
tokens: { in: tin, out: tout, cacheRead: tc, cacheWrite: 0 },
|
|
240
|
+
usd: usd === null ? null : usd,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Was the run doing anything after `since`? */
|
|
247
|
+
function activeSince(run, since) {
|
|
248
|
+
const times = [Date.parse(run.startedAt ?? "")];
|
|
249
|
+
for (const p of run.phases ?? [])
|
|
250
|
+
times.push(Date.parse(p.startedAt ?? ""), Date.parse(p.completedAt ?? ""));
|
|
251
|
+
return times.some((t) => Number.isFinite(t) && t >= since);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// The ledger
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @param {{runs?: object[], projectsRoot?: string, now?: number, verifiedAt?: string|null}} [opts]
|
|
259
|
+
*/
|
|
260
|
+
export async function officeLedger(opts = {}) {
|
|
261
|
+
const now = opts.now ?? Date.now();
|
|
262
|
+
const starts = windowStarts(now);
|
|
263
|
+
const since = Math.min(starts.week, starts.month);
|
|
264
|
+
const runs = (opts.runs ?? buildIndex()).map((r) => ({ ...r, extras: runExtras(r) }));
|
|
265
|
+
const projectsRoot = opts.projectsRoot ?? defaultProjectsRoot();
|
|
266
|
+
const transcriptsReadable = existsSync(projectsRoot);
|
|
267
|
+
|
|
268
|
+
const entries = [];
|
|
269
|
+
const trackerMeasured = new Set();
|
|
270
|
+
const repoOf = (run) => run.project ?? repoName(run.extras?.projectRoot) ?? UNKNOWN;
|
|
271
|
+
for (const run of runs) {
|
|
272
|
+
const list = trackerEntries(run, repoOf(run));
|
|
273
|
+
if (list.length) trackerMeasured.add(run.taskId);
|
|
274
|
+
entries.push(...list);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const seen = new Set();
|
|
278
|
+
const transcriptMeasured = new Set();
|
|
279
|
+
for (const file of transcriptFiles(projectsRoot, since)) {
|
|
280
|
+
const { rows, cwd, branch } = await rowsOf(file);
|
|
281
|
+
const hit = matchRun(file.sessionId, cwd, branch, runs);
|
|
282
|
+
const run = hit.run;
|
|
283
|
+
if (run && trackerMeasured.has(run.taskId)) continue;
|
|
284
|
+
for (const r of rows) {
|
|
285
|
+
if (r.ts < since) continue;
|
|
286
|
+
if (r.id) {
|
|
287
|
+
if (seen.has(r.id)) continue;
|
|
288
|
+
seen.add(r.id);
|
|
289
|
+
}
|
|
290
|
+
const rate = rateFor(r.model);
|
|
291
|
+
const phaseId = run ? phaseAt(run, r.ts) : null;
|
|
292
|
+
if (run) transcriptMeasured.add(run.taskId);
|
|
293
|
+
entries.push({
|
|
294
|
+
source: "transcript",
|
|
295
|
+
run: run ? run.taskId : null,
|
|
296
|
+
repo: run ? repoOf(run) : (repoName(cwd) ?? UNKNOWN),
|
|
297
|
+
model: modelLabel(r.model) ?? UNKNOWN,
|
|
298
|
+
phase: run
|
|
299
|
+
? phaseId === null
|
|
300
|
+
? UNKNOWN
|
|
301
|
+
: (PHASE_NAMES[String(phaseId)] ?? String(phaseId))
|
|
302
|
+
: OUTSIDE,
|
|
303
|
+
at: r.ts,
|
|
304
|
+
tokens: {
|
|
305
|
+
in: r.usage.input_tokens,
|
|
306
|
+
out: r.usage.output_tokens,
|
|
307
|
+
cacheRead: r.usage.cache_read_input_tokens,
|
|
308
|
+
cacheWrite: r.usage.cache_creation_input_tokens,
|
|
309
|
+
},
|
|
310
|
+
usd: rate ? priceUsage(rate, r.usage) : null,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const windows = {};
|
|
316
|
+
for (const [name, start] of Object.entries(starts)) {
|
|
317
|
+
const inWindow = entries.filter((e) => e.at !== null && e.at >= start && e.at <= now);
|
|
318
|
+
const total = newAmount();
|
|
319
|
+
const outside = newAmount();
|
|
320
|
+
const groups = { byRun: new Map(), byRepo: new Map(), byModel: new Map(), byPhase: new Map() };
|
|
321
|
+
const bump = (map, key, repo, e) => {
|
|
322
|
+
if (!map.has(key)) map.set(key, { amount: newAmount(), repo });
|
|
323
|
+
addTo(map.get(key).amount, e);
|
|
324
|
+
};
|
|
325
|
+
for (const e of inWindow) {
|
|
326
|
+
addTo(total, e);
|
|
327
|
+
if (!e.run) addTo(outside, e);
|
|
328
|
+
else bump(groups.byRun, e.run, e.repo, e);
|
|
329
|
+
bump(groups.byRepo, e.repo, null, e);
|
|
330
|
+
bump(groups.byModel, e.model, null, e);
|
|
331
|
+
bump(groups.byPhase, e.phase, null, e);
|
|
332
|
+
}
|
|
333
|
+
const notMeasured = runs
|
|
334
|
+
.filter(
|
|
335
|
+
(r) =>
|
|
336
|
+
!trackerMeasured.has(r.taskId) &&
|
|
337
|
+
!transcriptMeasured.has(r.taskId) &&
|
|
338
|
+
activeSince(r, start),
|
|
339
|
+
)
|
|
340
|
+
.map((r) => r.taskId)
|
|
341
|
+
.sort();
|
|
342
|
+
for (const id of notMeasured) {
|
|
343
|
+
const run = runs.find((r) => r.taskId === id);
|
|
344
|
+
groups.byRun.set(id, { amount: newAmount(), repo: repoOf(run) });
|
|
345
|
+
}
|
|
346
|
+
const rows = (map, withRepo) =>
|
|
347
|
+
[...map.entries()]
|
|
348
|
+
.map(([key, { amount, repo }]) => ({
|
|
349
|
+
key: String(key),
|
|
350
|
+
...(withRepo ? { repo } : {}),
|
|
351
|
+
...finish(amount),
|
|
352
|
+
}))
|
|
353
|
+
.sort((a, b) => (b.usd ?? -1) - (a.usd ?? -1) || (a.key < b.key ? -1 : 1));
|
|
354
|
+
windows[name] = {
|
|
355
|
+
since: new Date(start).toISOString(),
|
|
356
|
+
total: finish(total, { measuredEmpty: transcriptsReadable }),
|
|
357
|
+
outsidePipeline: finish(outside, { measuredEmpty: transcriptsReadable }),
|
|
358
|
+
notMeasured,
|
|
359
|
+
byRun: rows(groups.byRun, true),
|
|
360
|
+
byRepo: rows(groups.byRepo, false),
|
|
361
|
+
byModel: rows(groups.byModel, false),
|
|
362
|
+
byPhase: rows(groups.byPhase, false),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const verifiedAt = opts.verifiedAt === undefined ? (TABLE.verifiedAt ?? null) : opts.verifiedAt;
|
|
367
|
+
const age = priceAge(verifiedAt, now);
|
|
368
|
+
return {
|
|
369
|
+
contractVersion: CONTRACT_VERSION,
|
|
370
|
+
generatedAt: new Date(now).toISOString(),
|
|
371
|
+
prices: { table: "cost-table.json", verifiedAt, ...age },
|
|
372
|
+
windows,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// CLI
|
|
378
|
+
|
|
379
|
+
const money = (a) => (a.usd === null ? "not measured" : `$${a.usd.toFixed(2)}`);
|
|
380
|
+
|
|
381
|
+
function renderHuman(doc) {
|
|
382
|
+
const L = [];
|
|
383
|
+
for (const [name, w] of Object.entries(doc.windows)) {
|
|
384
|
+
L.push(`${name}: ${money(w.total)} (${w.total.source})`);
|
|
385
|
+
for (const r of w.byRun) L.push(` ${r.key}: ${money(r)} (${r.source})`);
|
|
386
|
+
L.push(` ${OUTSIDE}: ${money(w.outsidePipeline)} (${w.outsidePipeline.source})`);
|
|
387
|
+
}
|
|
388
|
+
if (doc.prices.warning) L.push(`note: ${doc.prices.warning}`);
|
|
389
|
+
L.push("Estimated at list price from cost-table.json, not an invoice.");
|
|
390
|
+
return L.join("\n") + "\n";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function main(argv) {
|
|
394
|
+
if (argv.some((a) => a !== "--json")) {
|
|
395
|
+
process.stderr.write("usage: office-ledger.mjs [--json]\n");
|
|
396
|
+
return 2;
|
|
397
|
+
}
|
|
398
|
+
const doc = await officeLedger();
|
|
399
|
+
process.stdout.write(
|
|
400
|
+
argv.includes("--json") ? JSON.stringify(doc, null, 2) + "\n" : renderHuman(doc),
|
|
401
|
+
);
|
|
402
|
+
return 0;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (invokedDirectly(import.meta.url)) {
|
|
406
|
+
runMain("office-ledger", async () => {
|
|
407
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
408
|
+
});
|
|
409
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file office-memory.mjs - what the pipeline has learned, per repository.
|
|
4
|
+
*
|
|
5
|
+
* The learning loop writes four stores, and this reads them without changing
|
|
6
|
+
* any:
|
|
7
|
+
*
|
|
8
|
+
* code graph ~/.claude/knowledge/<repo>/code-graph.json (graph-build.mjs)
|
|
9
|
+
* learnings ~/.claude/memory/multi-agent/<repo>/learnings-ledger.jsonl
|
|
10
|
+
* (learnings-ledger.mjs: durable facts, conventions, rejected
|
|
11
|
+
* preferences replayed into later runs)
|
|
12
|
+
* past findings .../triage-corpus.jsonl (triage-memory.mjs: review findings)
|
|
13
|
+
* session notes .../session-notes.jsonl (learn-from-transcripts.mjs)
|
|
14
|
+
*
|
|
15
|
+
* For each repository: when the graph was built and how large it is, how many
|
|
16
|
+
* learnings, findings and session notes there are, the newest learnings'
|
|
17
|
+
* statements (redacted and cut short) and when anything was last written. An
|
|
18
|
+
* empty shelf is reported as empty, so a repository the loop never fed shows.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* office-memory.mjs [--json]
|
|
22
|
+
*
|
|
23
|
+
* MA_MEMORY_ROOT and MA_KNOWLEDGE_ROOT override the two roots. Each store is
|
|
24
|
+
* read asynchronously: lines are counted in chunks and only the last
|
|
25
|
+
* MAX_BYTES are parsed, so a store that keeps growing never blocks the server.
|
|
26
|
+
*
|
|
27
|
+
* Exit: 0 printed, 2 usage.
|
|
28
|
+
*
|
|
29
|
+
* @module pipeline/scripts/office-memory
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { constants, createReadStream, existsSync, readdirSync, statSync } from "node:fs";
|
|
33
|
+
import { open } from "node:fs/promises";
|
|
34
|
+
import { homedir } from "node:os";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
|
|
37
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
38
|
+
import { createRedactor } from "./transcript-index.mjs";
|
|
39
|
+
|
|
40
|
+
export const CONTRACT_VERSION = "1.0.0";
|
|
41
|
+
const MAX_REPOS = 60;
|
|
42
|
+
const RECENT = 5;
|
|
43
|
+
const REPO = /^[A-Za-z0-9._-]{1,100}$/;
|
|
44
|
+
const MAX_BYTES = 1024 * 1024;
|
|
45
|
+
|
|
46
|
+
const roots = (env) => ({
|
|
47
|
+
memory: env.MA_MEMORY_ROOT || join(homedir(), ".claude", "memory", "multi-agent"),
|
|
48
|
+
knowledge: env.MA_KNOWLEDGE_ROOT || join(homedir(), ".claude", "knowledge"),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const dirsOf = (root) => {
|
|
52
|
+
try {
|
|
53
|
+
return readdirSync(root, { withFileTypes: true })
|
|
54
|
+
.filter((d) => d.isDirectory() && REPO.test(d.name))
|
|
55
|
+
.map((d) => d.name);
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// A line that is not a JSON object is skipped like an unreadable one.
|
|
62
|
+
const parse = (line) => {
|
|
63
|
+
try {
|
|
64
|
+
const v = JSON.parse(line);
|
|
65
|
+
return v && typeof v === "object" && !Array.isArray(v) ? [v] : [];
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// A store's lines, the same way for a small file read whole and a large one
|
|
72
|
+
// counted in chunks: every newline, and a last line that has none.
|
|
73
|
+
function linesIn(buf) {
|
|
74
|
+
let n = 0;
|
|
75
|
+
for (let i = buf.indexOf(10); i !== -1; i = buf.indexOf(10, i + 1)) n++;
|
|
76
|
+
return n + (buf.length && buf[buf.length - 1] !== 10 ? 1 : 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Counted through the handle already open, so the count is of the same file.
|
|
80
|
+
async function countLines(fh, size) {
|
|
81
|
+
let n = 0;
|
|
82
|
+
let last = 10;
|
|
83
|
+
const stream = createReadStream(null, { fd: fh.fd, start: 0, end: size - 1, autoClose: false });
|
|
84
|
+
for await (const chunk of stream) {
|
|
85
|
+
for (let i = chunk.indexOf(10); i !== -1; i = chunk.indexOf(10, i + 1)) n++;
|
|
86
|
+
if (chunk.length) last = chunk[chunk.length - 1];
|
|
87
|
+
}
|
|
88
|
+
return n + (size > 0 && last !== 10 ? 1 : 0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A store's line count and its last lines parsed (unreadable lines skipped);
|
|
93
|
+
* { count: 0, tail: [] } for no file. Only the last `maxBytes` are parsed.
|
|
94
|
+
*/
|
|
95
|
+
async function store(path, maxBytes) {
|
|
96
|
+
let fh;
|
|
97
|
+
try {
|
|
98
|
+
// Non-blocking and not through a link: a FIFO or a symlink planted in a
|
|
99
|
+
// store's place is skipped instead of holding a worker thread.
|
|
100
|
+
fh = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
|
|
101
|
+
const st = await fh.stat();
|
|
102
|
+
if (!st.isFile()) return { count: 0, tail: [] };
|
|
103
|
+
const { size } = st;
|
|
104
|
+
const start = Math.max(0, size - maxBytes);
|
|
105
|
+
const buf = Buffer.alloc(size - start);
|
|
106
|
+
await fh.read(buf, 0, buf.length, start);
|
|
107
|
+
let text = buf.toString("utf8");
|
|
108
|
+
if (start > 0) text = text.slice(text.indexOf("\n") + 1);
|
|
109
|
+
const lines = text.split("\n").filter(Boolean);
|
|
110
|
+
const count = start > 0 ? await countLines(fh, size) : linesIn(buf);
|
|
111
|
+
return { count, tail: lines.flatMap(parse) };
|
|
112
|
+
} catch {
|
|
113
|
+
return { count: 0, tail: [] };
|
|
114
|
+
} finally {
|
|
115
|
+
await fh?.close();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const ms = (v) => {
|
|
120
|
+
const t = Date.parse(v ?? "");
|
|
121
|
+
return Number.isFinite(t) ? t : null;
|
|
122
|
+
};
|
|
123
|
+
const iso = (t) => (t === null ? null : new Date(t).toISOString());
|
|
124
|
+
|
|
125
|
+
async function shelf(name, r, now, R, maxBytes) {
|
|
126
|
+
const dir = join(r.memory, name);
|
|
127
|
+
const graphPath = join(r.knowledge, name, "code-graph.json");
|
|
128
|
+
let graph = null;
|
|
129
|
+
if (existsSync(graphPath)) {
|
|
130
|
+
const st = statSync(graphPath);
|
|
131
|
+
graph = {
|
|
132
|
+
builtAt: iso(st.mtimeMs),
|
|
133
|
+
ageDays: Math.max(0, Math.floor((now - st.mtimeMs) / 86_400_000)),
|
|
134
|
+
sizeKb: Math.round(st.size / 1024),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const [learnings, findings, notes] = await Promise.all(
|
|
138
|
+
["learnings-ledger.jsonl", "triage-corpus.jsonl", "session-notes.jsonl"].map((f) =>
|
|
139
|
+
store(join(dir, f), maxBytes),
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
const times = [...learnings.tail, ...findings.tail, ...notes.tail]
|
|
143
|
+
.map((x) => ms(x.ts))
|
|
144
|
+
.filter((t) => t !== null);
|
|
145
|
+
if (graph) times.push(ms(graph.builtAt));
|
|
146
|
+
return {
|
|
147
|
+
repo: name,
|
|
148
|
+
graph,
|
|
149
|
+
learnings: learnings.count,
|
|
150
|
+
findings: findings.count,
|
|
151
|
+
sessionNotes: notes.count,
|
|
152
|
+
recent: learnings.tail
|
|
153
|
+
.slice(-RECENT)
|
|
154
|
+
.reverse()
|
|
155
|
+
.map((x) => ({
|
|
156
|
+
kind: typeof x.kind === "string" ? x.kind.slice(0, 30) : null,
|
|
157
|
+
statement: R.excerpt(String(x.statement ?? ""), 120) ?? "",
|
|
158
|
+
at: iso(ms(x.ts)),
|
|
159
|
+
})),
|
|
160
|
+
lastWrite: times.length ? iso(Math.max(...times)) : null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function officeMemory({
|
|
165
|
+
env = process.env,
|
|
166
|
+
now = Date.now(),
|
|
167
|
+
maxBytes = MAX_BYTES,
|
|
168
|
+
} = {}) {
|
|
169
|
+
const r = roots(env);
|
|
170
|
+
// The transcript redactor: credentials by provider rule as well as paths
|
|
171
|
+
// and addresses, the same pass the activity view uses.
|
|
172
|
+
const R = createRedactor();
|
|
173
|
+
const names = [...new Set([...dirsOf(r.memory), ...dirsOf(r.knowledge)])];
|
|
174
|
+
// One shelf that cannot be read is left out; the others still answer.
|
|
175
|
+
const read = await Promise.all(names.map((n) => shelf(n, r, now, R, maxBytes).catch(() => null)));
|
|
176
|
+
const shelves = read
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
.sort((a, b) => (b.lastWrite ?? "").localeCompare(a.lastWrite ?? ""))
|
|
179
|
+
.slice(0, MAX_REPOS);
|
|
180
|
+
return { contractVersion: CONTRACT_VERSION, generatedAt: new Date(now).toISOString(), shelves };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (invokedDirectly(import.meta.url)) {
|
|
184
|
+
const args = process.argv.slice(2);
|
|
185
|
+
if (args.some((a) => a !== "--json")) {
|
|
186
|
+
console.error("usage: office-memory.mjs [--json]");
|
|
187
|
+
process.exit(2);
|
|
188
|
+
}
|
|
189
|
+
const doc = await officeMemory();
|
|
190
|
+
process.stdout.write(
|
|
191
|
+
args.includes("--json")
|
|
192
|
+
? JSON.stringify(doc) + "\n"
|
|
193
|
+
: doc.shelves
|
|
194
|
+
.map(
|
|
195
|
+
(s) =>
|
|
196
|
+
`${s.repo}: graph ${s.graph ? s.graph.ageDays + "d old" : "none"}, ${s.learnings} learnings, ${s.findings} findings, ${s.sessionNotes} notes`,
|
|
197
|
+
)
|
|
198
|
+
.join("\n") + "\n",
|
|
199
|
+
);
|
|
200
|
+
}
|