portable-agent-layer 0.97.0 → 0.99.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.
Files changed (38) hide show
  1. package/README.md +1 -0
  2. package/package.json +2 -2
  3. package/src/cli/cost.ts +18 -0
  4. package/src/cli/events.ts +22 -0
  5. package/src/cli/tree.ts +2 -0
  6. package/src/hooks/lib/commitment-events.ts +3 -3
  7. package/src/hooks/lib/entity-events.ts +18 -5
  8. package/src/hooks/lib/events.ts +54 -4
  9. package/src/hooks/lib/interaction.ts +25 -5
  10. package/src/hooks/lib/ledger.ts +61 -2
  11. package/src/hooks/lib/models.ts +42 -10
  12. package/src/hooks/lib/remote.ts +3 -0
  13. package/src/hooks/lib/signals.ts +54 -4
  14. package/src/hooks/lib/work-tracking.ts +39 -2
  15. package/src/targets/lib.ts +1 -3
  16. package/src/tools/agent/project.ts +7 -2
  17. package/src/tools/knowledge/lib.ts +11 -2
  18. package/src/tools/lib/backfill/entities.ts +31 -0
  19. package/src/tools/lib/backfill/git.ts +184 -0
  20. package/src/tools/lib/backfill/index.ts +38 -0
  21. package/src/tools/lib/backfill/ledger.ts +28 -0
  22. package/src/tools/lib/backfill/projects.ts +40 -0
  23. package/src/tools/lib/backfill/promises.ts +91 -0
  24. package/src/tools/lib/backfill/ratings.ts +21 -0
  25. package/src/tools/lib/backfill/rows.ts +33 -0
  26. package/src/tools/lib/backfill/sessions.ts +56 -0
  27. package/src/tools/lib/backfill/turns.ts +32 -0
  28. package/src/tools/lib/collectors/billing.ts +105 -0
  29. package/src/tools/lib/collectors/claude.ts +61 -21
  30. package/src/tools/lib/collectors/codex.ts +6 -3
  31. package/src/tools/lib/collectors/copilot.ts +2 -0
  32. package/src/tools/lib/collectors/index.ts +8 -1
  33. package/src/tools/lib/collectors/opencode.ts +10 -2
  34. package/src/tools/lib/cost-report.ts +134 -0
  35. package/src/tools/lib/plan-prices.ts +24 -0
  36. package/src/tools/lib/project-history.ts +100 -0
  37. package/src/tools/lib/session-usage.ts +11 -7
  38. package/src/tools/lib/usage-buckets.ts +10 -5
@@ -28,6 +28,7 @@ import {
28
28
  } from "../../hooks/lib/projects";
29
29
  import { isServesKind, SERVES_KINDS, setServes } from "../../hooks/lib/serves";
30
30
  import { group, type Leaf, leaf, runCommand, UsageError } from "../lib/command";
31
+ import { decisionRow, emitHistoryRow, registrationRow } from "../lib/project-history";
31
32
  import {
32
33
  archiveLine,
33
34
  completeIsc,
@@ -169,6 +170,7 @@ function cmdCreate(given: string | undefined, values: CreateValues): undefined {
169
170
  ...(values["serves-note"] ? { serves_note: values["serves-note"] } : {}),
170
171
  };
171
172
  writeProject(project);
173
+ emitHistoryRow(registrationRow(project));
172
174
  ok({ created: true, project });
173
175
  }
174
176
 
@@ -258,11 +260,13 @@ function addDecision(name: string, decision: string, rationale: string): undefin
258
260
  const what = nonBlank(decision, "<decision>");
259
261
  const why = nonBlank(rationale, "<rationale...>");
260
262
  const p = requireProject(name);
261
- const date = new Date().toISOString().slice(0, 10);
263
+ const ts = now();
264
+ const date = ts.slice(0, 10);
262
265
  const line = `- ${date}: ${what} (${why})`;
263
266
  p.decisions = p.decisions ? `${p.decisions}\n${line}` : line;
264
- p.updated = now();
267
+ p.updated = ts;
265
268
  writeProject(p);
269
+ emitHistoryRow({ ...decisionRow(name, date, `${what} (${why})`), ts });
266
270
  ok({ updated: true, name });
267
271
  }
268
272
 
@@ -434,6 +438,7 @@ function cmdAddIsc(name: string, text: string): undefined {
434
438
  p.criteria = current ? `${current.trimEnd()}\n${newLine}` : newLine;
435
439
  p.updated = now();
436
440
  writeProject(p);
441
+ logCommitment(name, id, "opened", "cli");
437
442
  ok({
438
443
  added: true,
439
444
  id,
@@ -31,7 +31,7 @@ import {
31
31
  writeFileSync,
32
32
  } from "node:fs";
33
33
  import { resolve } from "node:path";
34
- import { logEntityFirstSeen } from "../../hooks/lib/entity-events";
34
+ import { logEntityFirstSeen, type SeenEntity } from "../../hooks/lib/entity-events";
35
35
  import { ensureDir, paths } from "../../hooks/lib/paths";
36
36
 
37
37
  // --- Constants --------------------------------------------------------------
@@ -411,6 +411,15 @@ export function exists(domain: Domain, slug: string, rootDir?: string): boolean
411
411
  return existsSync(entityPath(domain, slug, rootDir));
412
412
  }
413
413
 
414
+ export function seenEntity(entity: Entity): SeenEntity {
415
+ return {
416
+ domain: entity.domain,
417
+ slug: entity.slug,
418
+ entityType: entity.frontmatter.type,
419
+ name: entity.frontmatter.title,
420
+ };
421
+ }
422
+
414
423
  export function save(entity: Entity, rootDir?: string): void {
415
424
  const p = entityPath(entity.domain, entity.slug, rootDir);
416
425
  const firstSeenInTheStore = !rootDir && !existsSync(p);
@@ -418,7 +427,7 @@ export function save(entity: Entity, rootDir?: string): void {
418
427
  writeFileSync(tmp, serialize(entity), "utf-8");
419
428
  renameSync(tmp, p);
420
429
  if (firstSeenInTheStore) {
421
- logEntityFirstSeen(entity.domain, entity.frontmatter.type, entity.frontmatter.title);
430
+ logEntityFirstSeen(seenEntity(entity));
422
431
  }
423
432
  }
424
433
 
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Every entity in the knowledge store, first seen when it was created. The
3
+ * live event shares its id, so an entity already logged is skipped.
4
+ */
5
+
6
+ import { entityEventId, logEntityFirstSeen } from "../../../hooks/lib/entity-events";
7
+ import { type Entity, list, seenEntity } from "../../knowledge/lib";
8
+ import { emitUnseen } from "./rows";
9
+
10
+ interface EntityRow {
11
+ entity: Entity;
12
+ ts: string;
13
+ }
14
+
15
+ function emitEntity(row: EntityRow): void {
16
+ logEntityFirstSeen(seenEntity(row.entity), {
17
+ ts: row.ts,
18
+ session: null,
19
+ channel: null,
20
+ cwd: "",
21
+ attribution: { machine: "unknown", actor: "unknown", runtime: "unknown" },
22
+ });
23
+ }
24
+
25
+ /** Appends the entities not yet logged and returns how many. */
26
+ export function backfillEntities(known: Set<string>, since?: string): number {
27
+ const rows = list()
28
+ .filter((entity) => entity.frontmatter.created)
29
+ .map((entity) => ({ entity, ts: entity.frontmatter.created }));
30
+ return emitUnseen(rows, (row) => entityEventId(row.entity), emitEntity, known, since);
31
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Every commit that still exists in a registered project's repository, as a
3
+ * commit event whose id is its hash. Names, emails and the subject are text,
4
+ * so they go to the content store; the event keeps counts and flags.
5
+ */
6
+
7
+ import { spawnSync } from "node:child_process";
8
+ import { appendEvent } from "../../../hooks/lib/events";
9
+ import { PAL_REPO_URL } from "../../../hooks/lib/remote";
10
+
11
+ const RECORD = "\x1e";
12
+ const FIELD = "\x1f";
13
+ const FORMAT = [
14
+ "%H",
15
+ "%aI",
16
+ "%cI",
17
+ "%an",
18
+ "%ae",
19
+ "%cn",
20
+ "%ce",
21
+ "%D",
22
+ "%P",
23
+ "%s",
24
+ "%b",
25
+ "",
26
+ ]
27
+ .join(FIELD)
28
+ .replace(/^/, RECORD);
29
+ const AI_CO_AUTHOR = new RegExp(
30
+ String.raw`noreply@anthropic\.com|${PAL_REPO_URL.replace(/[.]/g, String.raw`\.`)}|copilot|cursor|openai|codex|gemini`,
31
+ "i"
32
+ );
33
+ const CONVENTIONAL = /^(\w+)(\([^)]*\))?(!)?:/;
34
+
35
+ interface Commit {
36
+ hash: string;
37
+ authoredAt: string;
38
+ committedAt: string;
39
+ author: string;
40
+ authorEmail: string;
41
+ committer: string;
42
+ committerEmail: string;
43
+ subject: string;
44
+ files: number;
45
+ added: number;
46
+ deleted: number;
47
+ commitType: string | null;
48
+ breaking: boolean;
49
+ revert: boolean;
50
+ merge: boolean;
51
+ tags: string[];
52
+ coAuthors: number;
53
+ aiCoAuthored: boolean;
54
+ }
55
+
56
+ function gitLog(repo: string, since: string | undefined): string | null {
57
+ const args = [
58
+ "-C",
59
+ repo,
60
+ "log",
61
+ "--branches",
62
+ "--tags",
63
+ "--remotes",
64
+ "--numstat",
65
+ `--format=${FORMAT}`,
66
+ ];
67
+ if (since) args.push(`--since=${since}`);
68
+ const result = spawnSync("git", args, {
69
+ encoding: "utf-8",
70
+ maxBuffer: 256 * 1024 * 1024,
71
+ });
72
+ return result.status === 0 ? result.stdout : null;
73
+ }
74
+
75
+ function numstatOf(text: string) {
76
+ const rows = text
77
+ .split("\n")
78
+ .map((line) => line.split("\t"))
79
+ .filter((cells) => cells.length === 3);
80
+ const count = (cell: string) => (cell === "-" ? 0 : Number(cell));
81
+ return {
82
+ files: rows.length,
83
+ added: rows.reduce((sum, [added]) => sum + count(added), 0),
84
+ deleted: rows.reduce((sum, [, deleted]) => sum + count(deleted), 0),
85
+ };
86
+ }
87
+
88
+ function coAuthorLines(body: string): string[] {
89
+ return body.split("\n").filter((line) => /^co-authored[- ]by\b/i.test(line.trim()));
90
+ }
91
+
92
+ function tagsOf(refs: string): string[] {
93
+ return refs
94
+ .split(", ")
95
+ .filter((ref) => ref.startsWith("tag: "))
96
+ .map((ref) => ref.slice("tag: ".length));
97
+ }
98
+
99
+ function commitOf(record: string): Commit | null {
100
+ const [hash, aI, cI, an, ae, cn, ce, refs, parents, subject, body, numstat] =
101
+ record.split(FIELD);
102
+ if (!hash || !aI || !cI) return null;
103
+ const conventional = CONVENTIONAL.exec(subject ?? "");
104
+ const coAuthors = coAuthorLines(body ?? "");
105
+ return {
106
+ hash,
107
+ authoredAt: new Date(aI).toISOString(),
108
+ committedAt: new Date(cI).toISOString(),
109
+ author: an ?? "",
110
+ authorEmail: ae ?? "",
111
+ committer: cn ?? "",
112
+ committerEmail: ce ?? "",
113
+ subject: subject ?? "",
114
+ ...numstatOf(numstat ?? ""),
115
+ commitType: conventional?.[1] ?? null,
116
+ breaking: !!conventional?.[3] || /^BREAKING CHANGE:/m.test(body ?? ""),
117
+ revert: conventional?.[1] === "revert" || (subject ?? "").startsWith("Revert "),
118
+ merge: (parents ?? "").trim().split(" ").length > 1,
119
+ tags: tagsOf(refs ?? ""),
120
+ coAuthors: coAuthors.length,
121
+ aiCoAuthored: coAuthors.some((line) => AI_CO_AUTHOR.test(line)),
122
+ };
123
+ }
124
+
125
+ function readCommits(repo: string, since?: string): Commit[] {
126
+ const log = gitLog(repo, since);
127
+ if (log === null) return [];
128
+ return log
129
+ .split(RECORD)
130
+ .map(commitOf)
131
+ .filter((commit): commit is Commit => commit !== null);
132
+ }
133
+
134
+ const commitEventId = (hash: string) => `git:${hash}`;
135
+
136
+ function emitCommit(repo: string, commit: Commit): void {
137
+ appendEvent(
138
+ "commit",
139
+ {
140
+ hash: commit.hash,
141
+ committedAt: commit.committedAt,
142
+ files: commit.files,
143
+ added: commit.added,
144
+ deleted: commit.deleted,
145
+ commitType: commit.commitType,
146
+ breaking: commit.breaking,
147
+ revert: commit.revert,
148
+ merge: commit.merge,
149
+ tags: commit.tags,
150
+ coAuthors: commit.coAuthors,
151
+ aiCoAuthored: commit.aiCoAuthored,
152
+ },
153
+ {
154
+ id: commitEventId(commit.hash),
155
+ ts: commit.authoredAt,
156
+ session: null,
157
+ runtime: "git",
158
+ channel: null,
159
+ cwd: repo,
160
+ content: {
161
+ author: commit.author,
162
+ authorEmail: commit.authorEmail,
163
+ committer: commit.committer,
164
+ committerEmail: commit.committerEmail,
165
+ subject: commit.subject,
166
+ },
167
+ }
168
+ );
169
+ }
170
+
171
+ /** Appends the commits not yet logged and returns how many. */
172
+ export function backfillGit(repos: string[], known: Set<string>, since?: string): number {
173
+ let emitted = 0;
174
+ for (const repo of repos) {
175
+ for (const commit of readCommits(repo, since)) {
176
+ const id = commitEventId(commit.hash);
177
+ if (known.has(id)) continue;
178
+ known.add(id);
179
+ emitCommit(repo, commit);
180
+ emitted++;
181
+ }
182
+ }
183
+ return emitted;
184
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Everything that still exists, read into the event log once. Every event's id
3
+ * comes from its source's own id, so a second run appends nothing.
4
+ */
5
+
6
+ import { loggedEventIds } from "../../../hooks/lib/events";
7
+ import { readAllProjects } from "../../../hooks/lib/projects";
8
+ import { eventLogEnabled } from "../../../hooks/lib/settings";
9
+ import { backfillEntities } from "./entities";
10
+ import { backfillGit } from "./git";
11
+ import { backfillLedger } from "./ledger";
12
+ import { backfillProjects } from "./projects";
13
+ import { backfillPromises } from "./promises";
14
+ import { backfillRatings } from "./ratings";
15
+ import { backfillSessions } from "./sessions";
16
+ import { backfillTurns } from "./turns";
17
+
18
+ function projectRepos(): string[] {
19
+ const paths = readAllProjects().flatMap((project) =>
20
+ project.path ? [project.path] : []
21
+ );
22
+ return [...new Set(paths)];
23
+ }
24
+
25
+ export function backfillEvents(since?: string): Record<string, number> {
26
+ if (!eventLogEnabled()) return {};
27
+ const known = loggedEventIds();
28
+ return {
29
+ git: backfillGit(projectRepos(), known, since),
30
+ sessions: backfillSessions(since),
31
+ turns: backfillTurns(known, since),
32
+ ratings: backfillRatings(known, since),
33
+ ledger: backfillLedger(known, since),
34
+ projects: backfillProjects(known, since),
35
+ promises: backfillPromises(known, since),
36
+ entities: backfillEntities(known, since),
37
+ };
38
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Every ledger entry, as the action event it would have streamed. Rows the
3
+ * test suite leaked into the real ledger before it was sandboxed are left out.
4
+ */
5
+
6
+ import {
7
+ actionEventId,
8
+ type LedgerEntry,
9
+ logActionEvent,
10
+ } from "../../../hooks/lib/ledger";
11
+ import { readLedger } from "../../ledger/query";
12
+ import { emitUnseen, recordedAttribution } from "./rows";
13
+
14
+ const SUITE_SANDBOXED_AT = "2026-09-23T14:35:01.000Z";
15
+
16
+ function leakedFromTestSuite(entry: LedgerEntry): boolean {
17
+ return entry.command === "rm -rf /" && entry.ts < SUITE_SANDBOXED_AT;
18
+ }
19
+
20
+ function emitAction(entry: LedgerEntry): void {
21
+ logActionEvent(entry, recordedAttribution(entry));
22
+ }
23
+
24
+ /** Appends the ledger entries not yet logged and returns how many. */
25
+ export function backfillLedger(known: Set<string>, since?: string): number {
26
+ const entries = readLedger().filter((entry) => !leakedFromTestSuite(entry));
27
+ return emitUnseen(entries, actionEventId, emitAction, known, since);
28
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What each project's ISA still records about its own history. An ISC's
3
+ * opening was never dated, so it cannot be backfilled; a close already
4
+ * streamed live as a commitment is not logged twice.
5
+ */
6
+
7
+ import { eventsSince } from "../../../hooks/lib/events";
8
+ import { readAllProjects } from "../../../hooks/lib/projects";
9
+ import {
10
+ closedIscRows,
11
+ decisionRows,
12
+ emitHistoryRow,
13
+ type HistoryRow,
14
+ registrationRow,
15
+ } from "../project-history";
16
+ import { emitUnseen, recordedAttribution } from "./rows";
17
+
18
+ const commitmentKey = (fields: Record<string, unknown>) =>
19
+ `${fields.iscProject}:${fields.isc}:${fields.outcome}`;
20
+
21
+ function liveCommitments(): Set<string> {
22
+ return new Set(eventsSince("commitment", new Date(0)).map(commitmentKey));
23
+ }
24
+
25
+ function emitHistory(row: HistoryRow): void {
26
+ emitHistoryRow(row, recordedAttribution(row));
27
+ }
28
+
29
+ /** Appends the project history not yet logged and returns how many events. */
30
+ export function backfillProjects(known: Set<string>, since?: string): number {
31
+ const live = liveCommitments();
32
+ const rows = readAllProjects()
33
+ .flatMap((project) => [
34
+ registrationRow(project),
35
+ ...closedIscRows(project),
36
+ ...decisionRows(project),
37
+ ])
38
+ .filter((row) => row.type !== "commitment" || !live.has(commitmentKey(row.fields)));
39
+ return emitUnseen(rows, (row) => row.id, emitHistory, known, since);
40
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The promises the Discord bot nags about, from its store: each one made, and
3
+ * each one closed as done or dropped. The title is text, so it goes to the
4
+ * content store.
5
+ */
6
+
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { appendEvent } from "../../../hooks/lib/events";
10
+ import { paths } from "../../../hooks/lib/paths";
11
+ import { emitUnseen } from "./rows";
12
+
13
+ interface StoredPromise {
14
+ id: number;
15
+ title: string;
16
+ due: string;
17
+ status: "open" | "done" | "dropped";
18
+ createdAt: string;
19
+ closedAt?: string;
20
+ project?: string;
21
+ snoozes: number;
22
+ nagCount: number;
23
+ }
24
+
25
+ interface PromiseRow {
26
+ id: string;
27
+ ts: string;
28
+ project: string | null;
29
+ fields: Record<string, unknown>;
30
+ title: string;
31
+ }
32
+
33
+ const secondsBetween = (from: string, to: string) =>
34
+ Math.round((Date.parse(to) - Date.parse(from)) / 1000);
35
+
36
+ function readPromises(): StoredPromise[] {
37
+ const file = resolve(paths.memory(), "commitments.json");
38
+ if (!existsSync(file)) return [];
39
+ try {
40
+ return JSON.parse(readFileSync(file, "utf-8")).items ?? [];
41
+ } catch {
42
+ return [];
43
+ }
44
+ }
45
+
46
+ function made(promise: StoredPromise): PromiseRow {
47
+ return {
48
+ id: `promise:${promise.id}:${promise.createdAt}:made`,
49
+ ts: promise.createdAt,
50
+ project: promise.project ?? null,
51
+ fields: { change: "made", dueInSec: secondsBetween(promise.createdAt, promise.due) },
52
+ title: promise.title,
53
+ };
54
+ }
55
+
56
+ function closed(promise: StoredPromise): PromiseRow[] {
57
+ if (promise.status === "open" || !promise.closedAt) return [];
58
+ return [
59
+ {
60
+ id: `promise:${promise.id}:${promise.createdAt}:${promise.status}`,
61
+ ts: promise.closedAt,
62
+ project: promise.project ?? null,
63
+ fields: {
64
+ change: promise.status,
65
+ lateSec: secondsBetween(promise.due, promise.closedAt),
66
+ snoozes: promise.snoozes,
67
+ nags: promise.nagCount,
68
+ },
69
+ title: promise.title,
70
+ },
71
+ ];
72
+ }
73
+
74
+ function emitPromise(row: PromiseRow): void {
75
+ appendEvent("promise", row.fields, {
76
+ id: row.id,
77
+ ts: row.ts,
78
+ session: null,
79
+ channel: null,
80
+ cwd: "",
81
+ project: row.project,
82
+ attribution: { machine: "unknown", actor: "unknown", runtime: "unknown" },
83
+ content: { title: row.title },
84
+ });
85
+ }
86
+
87
+ /** Appends the promises not yet logged and returns how many events. */
88
+ export function backfillPromises(known: Set<string>, since?: string): number {
89
+ const rows = readPromises().flatMap((promise) => [made(promise), ...closed(promise)]);
90
+ return emitUnseen(rows, (row) => row.id, emitPromise, known, since);
91
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Every rating in the signals file, as a rating event. The rated context and
3
+ * the reply preview are text, so they go to the content store.
4
+ */
5
+
6
+ import {
7
+ logRatingEvent,
8
+ type RatingSignal,
9
+ ratingEventId,
10
+ readRatings,
11
+ } from "../../../hooks/lib/signals";
12
+ import { emitUnseen, recordedAttribution } from "./rows";
13
+
14
+ function emitRating(row: RatingSignal): void {
15
+ logRatingEvent(row, { cwd: "", attribution: recordedAttribution(row) });
16
+ }
17
+
18
+ /** Appends the ratings not yet logged and returns how many. */
19
+ export function backfillRatings(known: Set<string>, since?: string): number {
20
+ return emitUnseen(readRatings(), ratingEventId, emitRating, known, since);
21
+ }
@@ -0,0 +1,33 @@
1
+ import type { RecordAttribution } from "../../../hooks/lib/actor";
2
+
3
+ export type AttributedRow = Partial<RecordAttribution> & { ts: string };
4
+
5
+ /** The origin a row was written with; a field it never carried is unknown, not this machine's. */
6
+ export function recordedAttribution(row: AttributedRow): Partial<RecordAttribution> {
7
+ return {
8
+ machine: row.machine ?? "unknown",
9
+ actor: row.actor ?? "unknown",
10
+ runtime: row.runtime ?? "unknown",
11
+ ...(row.authority && { authority: row.authority }),
12
+ };
13
+ }
14
+
15
+ /** Emits each row whose id is not logged yet and that falls on or after `since`; returns how many. */
16
+ export function emitUnseen<Row extends AttributedRow>(
17
+ rows: Row[],
18
+ idOf: (row: Row) => string,
19
+ emit: (row: Row, id: string) => void,
20
+ known: Set<string>,
21
+ since?: string
22
+ ): number {
23
+ const from = new Date(since ?? 0).toISOString();
24
+ let emitted = 0;
25
+ for (const row of rows) {
26
+ const id = idOf(row);
27
+ if (known.has(id) || row.ts < from) continue;
28
+ known.add(id);
29
+ emit(row, id);
30
+ emitted++;
31
+ }
32
+ return emitted;
33
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Every session PAL still has a record of, as the session_end it would have
3
+ * logged. A record is the session's last known state, so it carries no reason,
4
+ * duration or turn count; its name, summary and handoff are text and go to the
5
+ * content store.
6
+ */
7
+
8
+ import { appendEvent, eventsSince } from "../../../hooks/lib/events";
9
+ import { readSessionHistory, type SessionRecord } from "../../../hooks/lib/work-tracking";
10
+
11
+ const sessionEventId = (sessionId: string) => `session:${sessionId}`;
12
+
13
+ function endedSessions(): Set<string> {
14
+ return new Set(
15
+ eventsSince("session_end", new Date(0)).flatMap((e) => (e.session ? [e.session] : []))
16
+ );
17
+ }
18
+
19
+ function emitSession(record: SessionRecord): void {
20
+ appendEvent(
21
+ "session_end",
22
+ {
23
+ reason: "backfill",
24
+ durationSec: null,
25
+ turns: null,
26
+ messages: record.messageCount,
27
+ status: record.status,
28
+ },
29
+ {
30
+ id: sessionEventId(record.sessionId),
31
+ ts: record.ts,
32
+ session: record.sessionId,
33
+ runtime: "unknown",
34
+ channel: null,
35
+ cwd: record.cwd,
36
+ content: {
37
+ name: record.name,
38
+ summary: record.summary,
39
+ handoff: record.handoff,
40
+ artifacts: record.artifacts,
41
+ },
42
+ }
43
+ );
44
+ }
45
+
46
+ /** Appends the sessions with no session_end yet and returns how many. */
47
+ export function backfillSessions(since?: string): number {
48
+ const ended = endedSessions();
49
+ const fresh = readSessionHistory().filter(
50
+ (record) =>
51
+ !ended.has(record.sessionId) &&
52
+ (!since || record.ts >= new Date(since).toISOString())
53
+ );
54
+ for (const record of fresh) emitSession(record);
55
+ return fresh.length;
56
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Every turn measured before turns streamed into the event log. A row keeps
3
+ * the machine, actor and runtime it was written with, since an imported file
4
+ * holds other machines' rows too.
5
+ */
6
+
7
+ import { appendEvent } from "../../../hooks/lib/events";
8
+ import {
9
+ legacyTurnId,
10
+ legacyTurns,
11
+ type TurnEvent,
12
+ } from "../../../hooks/lib/interaction";
13
+ import { type AttributedRow, emitUnseen, recordedAttribution } from "./rows";
14
+
15
+ type LegacyTurn = TurnEvent & AttributedRow;
16
+
17
+ function emitTurn(turn: LegacyTurn, id: string): void {
18
+ const { ts, session, channel, ...fields } = turn;
19
+ appendEvent("turn", fields, {
20
+ id,
21
+ ts,
22
+ session,
23
+ channel: channel ?? null,
24
+ cwd: "",
25
+ attribution: recordedAttribution(turn),
26
+ });
27
+ }
28
+
29
+ /** Appends the legacy turns not yet logged and returns how many. */
30
+ export function backfillTurns(known: Set<string>, since?: string): number {
31
+ return emitUnseen(legacyTurns() as LegacyTurn[], legacyTurnId, emitTurn, known, since);
32
+ }