portable-agent-layer 0.98.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.
- package/package.json +2 -2
- package/src/hooks/lib/commitment-events.ts +3 -3
- package/src/hooks/lib/entity-events.ts +18 -5
- package/src/hooks/lib/events.ts +10 -1
- package/src/tools/agent/project.ts +7 -2
- package/src/tools/knowledge/lib.ts +11 -2
- package/src/tools/lib/backfill/entities.ts +31 -0
- package/src/tools/lib/backfill/index.ts +6 -0
- package/src/tools/lib/backfill/projects.ts +40 -0
- package/src/tools/lib/backfill/promises.ts +91 -0
- package/src/tools/lib/collectors/claude.ts +48 -16
- package/src/tools/lib/collectors/index.ts +2 -1
- package/src/tools/lib/project-history.ts +100 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portable-agent-layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.99.0",
|
|
4
4
|
"description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"@commitlint/cli": "21.0.1",
|
|
81
81
|
"@commitlint/config-conventional": "21.0.1",
|
|
82
82
|
"@hughescr/stryker-bun-runner": "1.3.8",
|
|
83
|
-
"@konvert7/klint": "0.
|
|
83
|
+
"@konvert7/klint": "0.35.1",
|
|
84
84
|
"@opencode-ai/plugin": "latest",
|
|
85
85
|
"@secretlint/secretlint-rule-preset-recommend": "13.0.2",
|
|
86
86
|
"@semantic-release/changelog": "^6.0.3",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A commitment is an ISC:
|
|
3
|
-
* snooze puts it off. Each change of state is one event, whichever surface made it.
|
|
2
|
+
* A commitment is an ISC: adding it opens it, completing it keeps it, retiring
|
|
3
|
+
* it drops it, and a snooze puts it off. Each change of state is one event, whichever surface made it.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { appendEvent, logQuietly } from "./events";
|
|
7
7
|
|
|
8
|
-
export type CommitmentOutcome = "kept" | "dropped" | "snoozed" | "reopened";
|
|
8
|
+
export type CommitmentOutcome = "opened" | "kept" | "dropped" | "snoozed" | "reopened";
|
|
9
9
|
|
|
10
10
|
export function logCommitment(
|
|
11
11
|
iscProject: string,
|
|
@@ -3,14 +3,27 @@
|
|
|
3
3
|
* store. The name stays in the content store; the event carries only its kind.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { appendEvent, logQuietly } from "./events";
|
|
6
|
+
import { type AppendOptions, appendEvent, logQuietly } from "./events";
|
|
7
|
+
|
|
8
|
+
export interface SeenEntity {
|
|
9
|
+
domain: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
entityType: string;
|
|
12
|
+
name: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const entityEventId = (entity: Pick<SeenEntity, "domain" | "slug">) =>
|
|
16
|
+
`entity:${entity.domain}:${entity.slug}`;
|
|
7
17
|
|
|
8
18
|
export function logEntityFirstSeen(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
name: string
|
|
19
|
+
entity: SeenEntity,
|
|
20
|
+
options: AppendOptions = {}
|
|
12
21
|
): void {
|
|
13
22
|
logQuietly("entity_first_seen", () =>
|
|
14
|
-
appendEvent(
|
|
23
|
+
appendEvent(
|
|
24
|
+
"entity_first_seen",
|
|
25
|
+
{ domain: entity.domain, entityType: entity.entityType },
|
|
26
|
+
{ id: entityEventId(entity), content: { name: entity.name }, ...options }
|
|
27
|
+
)
|
|
15
28
|
);
|
|
16
29
|
}
|
package/src/hooks/lib/events.ts
CHANGED
|
@@ -116,6 +116,9 @@ export const EVENT_FIELDS = {
|
|
|
116
116
|
"aiCoAuthored",
|
|
117
117
|
],
|
|
118
118
|
rating: ["rating", "source"],
|
|
119
|
+
project: ["change", "status"],
|
|
120
|
+
decision: ["words"],
|
|
121
|
+
promise: ["change", "dueInSec", "lateSec", "snoozes", "nags"],
|
|
119
122
|
action: [
|
|
120
123
|
"tool",
|
|
121
124
|
"outcome",
|
|
@@ -178,6 +181,7 @@ export interface AppendOptions {
|
|
|
178
181
|
session?: string | null;
|
|
179
182
|
runtime?: AgentType | "git" | "unknown";
|
|
180
183
|
attribution?: Partial<RecordAttribution>;
|
|
184
|
+
project?: string | null;
|
|
181
185
|
channel?: string | null;
|
|
182
186
|
cwd?: string;
|
|
183
187
|
content?: Record<string, unknown>;
|
|
@@ -241,11 +245,16 @@ function envelope(type: EventType, options: AppendOptions): EventEnvelope {
|
|
|
241
245
|
...options.attribution,
|
|
242
246
|
channel:
|
|
243
247
|
options.channel !== undefined ? options.channel : process.env.PAL_CHANNEL || null,
|
|
244
|
-
project:
|
|
248
|
+
project: projectFor(options, cwd),
|
|
245
249
|
cwd,
|
|
246
250
|
};
|
|
247
251
|
}
|
|
248
252
|
|
|
253
|
+
function projectFor(options: AppendOptions, cwd: string): string | null {
|
|
254
|
+
if (options.project !== undefined) return options.project;
|
|
255
|
+
return cwd ? projectOf(cwd) : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
249
258
|
/** Appends one event and returns its id, or null when the log is off or the write failed. */
|
|
250
259
|
export function appendEvent(
|
|
251
260
|
type: EventType,
|
|
@@ -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
|
|
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 =
|
|
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
|
|
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
|
+
}
|
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
import { loggedEventIds } from "../../../hooks/lib/events";
|
|
7
7
|
import { readAllProjects } from "../../../hooks/lib/projects";
|
|
8
8
|
import { eventLogEnabled } from "../../../hooks/lib/settings";
|
|
9
|
+
import { backfillEntities } from "./entities";
|
|
9
10
|
import { backfillGit } from "./git";
|
|
10
11
|
import { backfillLedger } from "./ledger";
|
|
12
|
+
import { backfillProjects } from "./projects";
|
|
13
|
+
import { backfillPromises } from "./promises";
|
|
11
14
|
import { backfillRatings } from "./ratings";
|
|
12
15
|
import { backfillSessions } from "./sessions";
|
|
13
16
|
import { backfillTurns } from "./turns";
|
|
@@ -28,5 +31,8 @@ export function backfillEvents(since?: string): Record<string, number> {
|
|
|
28
31
|
turns: backfillTurns(known, since),
|
|
29
32
|
ratings: backfillRatings(known, since),
|
|
30
33
|
ledger: backfillLedger(known, since),
|
|
34
|
+
projects: backfillProjects(known, since),
|
|
35
|
+
promises: backfillPromises(known, since),
|
|
36
|
+
entities: backfillEntities(known, since),
|
|
31
37
|
};
|
|
32
38
|
}
|
|
@@ -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
|
+
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* cursor stops where it starts.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { readFileSync, statSync } from "node:fs";
|
|
11
|
-
import { relative, sep } from "node:path";
|
|
10
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
11
|
+
import { relative, resolve, sep } from "node:path";
|
|
12
12
|
import { appendEvent } from "../../../hooks/lib/events";
|
|
13
13
|
import { cacheWritesOf, type TranscriptUsage } from "../transcript-usage";
|
|
14
14
|
import { claudeTranscripts } from "../usage-buckets";
|
|
@@ -60,8 +60,7 @@ function parsed(text: string): ClaudeLine | null {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
function completeLinesFrom(
|
|
64
|
-
const bytes = readFileSync(filepath);
|
|
63
|
+
function completeLinesFrom(bytes: Buffer, offset: number) {
|
|
65
64
|
const end = bytes.lastIndexOf(10) + 1;
|
|
66
65
|
if (end <= offset) return { lines: [], end: offset };
|
|
67
66
|
const lines: { text: string; start: number }[] = [];
|
|
@@ -76,12 +75,8 @@ function completeLinesFrom(filepath: string, offset: number) {
|
|
|
76
75
|
return { lines, end };
|
|
77
76
|
}
|
|
78
77
|
|
|
79
|
-
function settledMessages(
|
|
80
|
-
|
|
81
|
-
offset: number,
|
|
82
|
-
settled: boolean
|
|
83
|
-
): SettledRead {
|
|
84
|
-
const { lines, end } = completeLinesFrom(filepath, offset);
|
|
78
|
+
function settledMessages(bytes: Buffer, offset: number, settled: boolean): SettledRead {
|
|
79
|
+
const { lines, end } = completeLinesFrom(bytes, offset);
|
|
85
80
|
const groups = new Map<string, { start: number; line: ModelCallLine }>();
|
|
86
81
|
let newest: string | undefined;
|
|
87
82
|
for (const { text, start } of lines) {
|
|
@@ -136,21 +131,58 @@ function isSettled(filepath: string, now: Date): boolean {
|
|
|
136
131
|
return now.getTime() - statSync(filepath).mtimeMs > SETTLE_MS;
|
|
137
132
|
}
|
|
138
133
|
|
|
134
|
+
interface Transcript {
|
|
135
|
+
key: string;
|
|
136
|
+
bytes: () => Buffer;
|
|
137
|
+
settled: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function liveTranscripts(projectsDir: string, now: Date): Transcript[] {
|
|
141
|
+
return claudeTranscripts(projectsDir).map((filepath) => ({
|
|
142
|
+
key: relative(projectsDir, filepath).split(sep).join("/"),
|
|
143
|
+
bytes: () => readFileSync(filepath),
|
|
144
|
+
settled: isSettled(filepath, now),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function copiesOfDeletedTranscripts(
|
|
149
|
+
copiesDir: string,
|
|
150
|
+
projectsDir: string
|
|
151
|
+
): Transcript[] {
|
|
152
|
+
if (!existsSync(copiesDir)) return [];
|
|
153
|
+
return readdirSync(copiesDir, { recursive: true, encoding: "utf-8" })
|
|
154
|
+
.map((file) => file.split(sep).join("/"))
|
|
155
|
+
.filter((file) => file.endsWith(".jsonl.gz"))
|
|
156
|
+
.map((file) => file.slice(0, -".gz".length))
|
|
157
|
+
.filter((key) => !existsSync(resolve(projectsDir, key)))
|
|
158
|
+
.map((key) => ({
|
|
159
|
+
key,
|
|
160
|
+
bytes: () =>
|
|
161
|
+
Buffer.from(Bun.gunzipSync(readFileSync(resolve(copiesDir, `${key}.gz`)))),
|
|
162
|
+
settled: true,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Reads each transcript from its cursor; one Claude Code already deleted is read from PAL's copy. */
|
|
139
167
|
export function collectClaude(
|
|
140
168
|
projectsDir: string,
|
|
141
169
|
now: Date = new Date(),
|
|
142
|
-
billingAt: (at: string) => Billing = () => UNKNOWN
|
|
170
|
+
billingAt: (at: string) => Billing = () => UNKNOWN,
|
|
171
|
+
copiesDir?: string
|
|
143
172
|
): number {
|
|
144
173
|
const cursor = readCursor("claude");
|
|
145
174
|
let emitted = 0;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
175
|
+
const transcripts = [
|
|
176
|
+
...liveTranscripts(projectsDir, now),
|
|
177
|
+
...(copiesDir ? copiesOfDeletedTranscripts(copiesDir, projectsDir) : []),
|
|
178
|
+
];
|
|
179
|
+
for (const transcript of transcripts) {
|
|
180
|
+
const offset = Number(cursor[transcript.key] ?? 0);
|
|
181
|
+
const read = settledMessages(transcript.bytes(), offset, transcript.settled);
|
|
150
182
|
if (read.resumeAt === offset) continue;
|
|
151
183
|
for (const line of read.messages) emitModelCall(line, billingAt(line.timestamp));
|
|
152
184
|
emitted += read.messages.length;
|
|
153
|
-
cursor[key] = read.resumeAt;
|
|
185
|
+
cursor[transcript.key] = read.resumeAt;
|
|
154
186
|
writeCursor("claude", cursor);
|
|
155
187
|
}
|
|
156
188
|
return emitted;
|
|
@@ -25,7 +25,8 @@ const COLLECTORS: Record<string, () => number> = {
|
|
|
25
25
|
collectClaude(
|
|
26
26
|
resolve(platform.claudeDir(), "projects"),
|
|
27
27
|
new Date(),
|
|
28
|
-
claudeBilling(claudeConfigFile())
|
|
28
|
+
claudeBilling(claudeConfigFile()),
|
|
29
|
+
resolve(paths.content(), "claude")
|
|
29
30
|
),
|
|
30
31
|
opencode: () => collectOpencode(opencodeDatabase()),
|
|
31
32
|
codex: () => collectCodex(platform.codexDir()),
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A project's history as events: its registration, its dated decisions, and
|
|
3
|
+
* each ISC it closed or retired as the commitment kept or dropped. The live
|
|
4
|
+
* commands and the backfill build the same rows, so both give one id.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RecordAttribution } from "../../hooks/lib/actor";
|
|
8
|
+
import { appendEvent } from "../../hooks/lib/events";
|
|
9
|
+
import type { ProjectProgress } from "../../hooks/lib/projects";
|
|
10
|
+
import { parseIscs } from "./project-isc";
|
|
11
|
+
|
|
12
|
+
export interface HistoryRow {
|
|
13
|
+
id: string;
|
|
14
|
+
ts: string;
|
|
15
|
+
type: "project" | "commitment" | "decision";
|
|
16
|
+
project: string;
|
|
17
|
+
fields: Record<string, unknown>;
|
|
18
|
+
content?: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ARCHIVE_HEADING = /^### (Archived|Retired) (\d{4}-\d{2}-\d{2})$/;
|
|
22
|
+
const DECISION_LINE = /^- (\d{4}-\d{2}-\d{2}): (.+)$/;
|
|
23
|
+
|
|
24
|
+
const dayStart = (day: string) => `${day}T00:00:00.000Z`;
|
|
25
|
+
|
|
26
|
+
function shortHash(text: string): string {
|
|
27
|
+
return new Bun.CryptoHasher("sha256").update(text).digest("hex").slice(0, 12);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registrationRow(
|
|
31
|
+
project: Pick<ProjectProgress, "name" | "created" | "status">
|
|
32
|
+
): HistoryRow {
|
|
33
|
+
return {
|
|
34
|
+
id: `project:${project.name}:registered`,
|
|
35
|
+
ts: project.created,
|
|
36
|
+
type: "project",
|
|
37
|
+
project: project.name,
|
|
38
|
+
fields: { change: "registered", status: project.status },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function decisionRow(project: string, day: string, text: string): HistoryRow {
|
|
43
|
+
return {
|
|
44
|
+
id: `decision:${project}:${day}:${shortHash(text)}`,
|
|
45
|
+
ts: dayStart(day),
|
|
46
|
+
type: "decision",
|
|
47
|
+
project,
|
|
48
|
+
fields: { words: text.split(/\s+/).length },
|
|
49
|
+
content: { text },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function decisionRows(project: ProjectProgress): HistoryRow[] {
|
|
54
|
+
return (project.decisions ?? "").split("\n").flatMap((line) => {
|
|
55
|
+
const decision = DECISION_LINE.exec(line);
|
|
56
|
+
return decision ? [decisionRow(project.name, decision[1], decision[2])] : [];
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function closedIscRows(project: ProjectProgress): HistoryRow[] {
|
|
61
|
+
const rows: HistoryRow[] = [];
|
|
62
|
+
let heading: RegExpExecArray | null = null;
|
|
63
|
+
for (const line of (project.changelog ?? "").split("\n")) {
|
|
64
|
+
heading = ARCHIVE_HEADING.exec(line) ?? heading;
|
|
65
|
+
const [isc] = parseIscs(line);
|
|
66
|
+
if (!heading || !isc) continue;
|
|
67
|
+
const outcome = heading[1] === "Retired" ? "dropped" : "kept";
|
|
68
|
+
rows.push({
|
|
69
|
+
id: `commitment:${project.name}:${isc.id}:${outcome}:${heading[2]}`,
|
|
70
|
+
ts: dayStart(heading[2]),
|
|
71
|
+
type: "commitment",
|
|
72
|
+
project: project.name,
|
|
73
|
+
fields: {
|
|
74
|
+
iscProject: project.name,
|
|
75
|
+
isc: isc.id,
|
|
76
|
+
outcome,
|
|
77
|
+
surface: null,
|
|
78
|
+
snoozeDays: null,
|
|
79
|
+
},
|
|
80
|
+
content: { text: isc.text },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return rows;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function emitHistoryRow(
|
|
87
|
+
row: HistoryRow,
|
|
88
|
+
attribution: Partial<RecordAttribution> = {}
|
|
89
|
+
): string | null {
|
|
90
|
+
return appendEvent(row.type, row.fields, {
|
|
91
|
+
id: row.id,
|
|
92
|
+
ts: row.ts,
|
|
93
|
+
session: null,
|
|
94
|
+
channel: null,
|
|
95
|
+
cwd: "",
|
|
96
|
+
project: row.project,
|
|
97
|
+
attribution,
|
|
98
|
+
...(row.content && { content: row.content }),
|
|
99
|
+
});
|
|
100
|
+
}
|