@jitsusama/agentic-harness.core 0.3.0 → 0.4.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/dist/google/apis/calendar.js +1 -1
- package/dist/google/apis/client.d.ts +10 -0
- package/dist/google/apis/client.js +23 -0
- package/dist/google/apis/docs.js +1 -1
- package/dist/google/apis/drive.js +1 -1
- package/dist/google/apis/gmail.js +1 -1
- package/dist/google/apis/sheets.js +1 -1
- package/dist/google/apis/slides.js +1 -1
- package/dist/observability/ledger/scan.d.ts +11 -0
- package/dist/observability/ledger/scan.js +159 -0
- package/dist/observability/ledger/session.d.ts +38 -0
- package/dist/observability/ledger/session.js +89 -0
- package/dist/observability/ledger/store.d.ts +3 -1
- package/dist/observability/ledger/store.js +42 -2
- package/dist/observability/ledger/types.d.ts +7 -0
- package/dist/slack/auth/browser-extract.js +3 -1
- package/dist/web/browser.d.ts +1 -1
- package/dist/web/browser.js +10 -3
- package/dist/web/reader.js +10 -5
- package/dist/web/session/emulation.js +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The googleapis client, loaded on first use.
|
|
3
|
+
*
|
|
4
|
+
* googleapis bundles a client for every Google API, about 1,800
|
|
5
|
+
* modules, and every extension that imports this package would load
|
|
6
|
+
* all of them at startup. This stands in for its `google` export and
|
|
7
|
+
* loads the real one the first time any API is asked for.
|
|
8
|
+
*/
|
|
9
|
+
import type { GoogleApis } from "googleapis";
|
|
10
|
+
export declare const google: GoogleApis;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The googleapis client, loaded on first use.
|
|
3
|
+
*
|
|
4
|
+
* googleapis bundles a client for every Google API, about 1,800
|
|
5
|
+
* modules, and every extension that imports this package would load
|
|
6
|
+
* all of them at startup. This stands in for its `google` export and
|
|
7
|
+
* loads the real one the first time any API is asked for.
|
|
8
|
+
*/
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
let loaded;
|
|
12
|
+
function load() {
|
|
13
|
+
loaded ??= require("googleapis").google;
|
|
14
|
+
return loaded;
|
|
15
|
+
}
|
|
16
|
+
export const google = new Proxy({}, {
|
|
17
|
+
get(_, property) {
|
|
18
|
+
const real = load();
|
|
19
|
+
const value = Reflect.get(real, property);
|
|
20
|
+
// The API factories read shared options off `this`.
|
|
21
|
+
return typeof value === "function" ? value.bind(real) : value;
|
|
22
|
+
},
|
|
23
|
+
});
|
package/dist/google/apis/docs.js
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { LedgerScan } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Read billable turns out of a session log's lines.
|
|
4
|
+
*
|
|
5
|
+
* Every line is offered to the parser and every outcome is counted, so a
|
|
6
|
+
* malformed line costs one entry rather than the remainder of the file.
|
|
7
|
+
* Both places a turn can carry usage are read: assistant turns hold it
|
|
8
|
+
* under `message`, and compactions hold it at the top level beside
|
|
9
|
+
* `type`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function readTurns(sessionId: string, lines: Iterable<string>): LedgerScan;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { SessionCollector } from "./session.js";
|
|
3
|
+
/** Width of a stored content address. 96 bits is ample for a corpus of
|
|
4
|
+
* a few million turns and keeps the index small. */
|
|
5
|
+
const DIGEST_CHARS = 24;
|
|
6
|
+
/**
|
|
7
|
+
* Read billable turns out of a session log's lines.
|
|
8
|
+
*
|
|
9
|
+
* Every line is offered to the parser and every outcome is counted, so a
|
|
10
|
+
* malformed line costs one entry rather than the remainder of the file.
|
|
11
|
+
* Both places a turn can carry usage are read: assistant turns hold it
|
|
12
|
+
* under `message`, and compactions hold it at the top level beside
|
|
13
|
+
* `type`.
|
|
14
|
+
*/
|
|
15
|
+
export function readTurns(sessionId, lines) {
|
|
16
|
+
const turns = [];
|
|
17
|
+
const session = new SessionCollector(sessionId);
|
|
18
|
+
let count = 0;
|
|
19
|
+
let parsed = 0;
|
|
20
|
+
let unparseable = 0;
|
|
21
|
+
let billable = 0;
|
|
22
|
+
let unmetered = 0;
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
count += 1;
|
|
25
|
+
if (!line.trim())
|
|
26
|
+
continue;
|
|
27
|
+
let entry;
|
|
28
|
+
try {
|
|
29
|
+
const value = JSON.parse(line);
|
|
30
|
+
if (typeof value !== "object" || value === null) {
|
|
31
|
+
unparseable += 1;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
entry = value;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// A truncated or interleaved write. Counted, never fatal: one
|
|
38
|
+
// unreadable line once reduced a corpus-wide total to a tenth
|
|
39
|
+
// of the truth by aborting the pipeline that met it.
|
|
40
|
+
unparseable += 1;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
parsed += 1;
|
|
44
|
+
if (entry.type === "session") {
|
|
45
|
+
session.observeHeader(entry);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (entry.customType === "quest-workflow") {
|
|
49
|
+
const data = asRecord(entry.data);
|
|
50
|
+
if (data)
|
|
51
|
+
session.observeWorkflow(data);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const turn = turnFrom(sessionId, entry);
|
|
55
|
+
if (!turn)
|
|
56
|
+
continue;
|
|
57
|
+
session.observeTurn(turn.timestamp);
|
|
58
|
+
turns.push(turn);
|
|
59
|
+
if (turn.cost)
|
|
60
|
+
billable += 1;
|
|
61
|
+
else
|
|
62
|
+
unmetered += 1;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
turns,
|
|
66
|
+
coverage: { lines: count, parsed, unparseable, billable, unmetered },
|
|
67
|
+
session: session.record(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function turnFrom(sessionId, entry) {
|
|
71
|
+
const kind = kindOf(entry);
|
|
72
|
+
if (!kind)
|
|
73
|
+
return null;
|
|
74
|
+
const message = asRecord(entry.message);
|
|
75
|
+
const usage = asRecord(kind === "compaction" ? entry.usage : message?.usage);
|
|
76
|
+
const entryId = typeof entry.id === "string" ? entry.id : "";
|
|
77
|
+
const timestamp = typeof entry.timestamp === "string" ? entry.timestamp : "";
|
|
78
|
+
const model = typeof message?.model === "string" ? message.model : "";
|
|
79
|
+
return {
|
|
80
|
+
entryId,
|
|
81
|
+
sessionId,
|
|
82
|
+
timestamp,
|
|
83
|
+
kind,
|
|
84
|
+
model,
|
|
85
|
+
tokens: tokensFrom(usage),
|
|
86
|
+
cost: costFrom(usage),
|
|
87
|
+
cacheWrite1h: usage?.cacheWrite1h ?? 0,
|
|
88
|
+
droppedBefore: kind === "compaction" && typeof entry.tokensBefore === "number"
|
|
89
|
+
? entry.tokensBefore
|
|
90
|
+
: null,
|
|
91
|
+
firstKeptEntryId: typeof entry.firstKeptEntryId === "string"
|
|
92
|
+
? entry.firstKeptEntryId
|
|
93
|
+
: null,
|
|
94
|
+
digest: digestOf(entryId, timestamp, kind, usage),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Which turns are billable at all. An assistant turn and a compaction
|
|
99
|
+
* both cost money; a user message, a tool result and a state change do
|
|
100
|
+
* not. Enumerated rather than filtered, so a new entry type is ignored
|
|
101
|
+
* by omission instead of silently swept into a total.
|
|
102
|
+
*/
|
|
103
|
+
function kindOf(entry) {
|
|
104
|
+
if (entry.type === "compaction")
|
|
105
|
+
return "compaction";
|
|
106
|
+
const message = asRecord(entry.message);
|
|
107
|
+
if (message?.role === "assistant")
|
|
108
|
+
return "assistant";
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
function tokensFrom(usage) {
|
|
112
|
+
const input = usage?.input ?? 0;
|
|
113
|
+
const output = usage?.output ?? 0;
|
|
114
|
+
const cacheRead = usage?.cacheRead ?? 0;
|
|
115
|
+
const cacheWrite = usage?.cacheWrite ?? 0;
|
|
116
|
+
return {
|
|
117
|
+
input,
|
|
118
|
+
output,
|
|
119
|
+
cacheRead,
|
|
120
|
+
cacheWrite,
|
|
121
|
+
total: usage?.totalTokens ?? input + output + cacheRead + cacheWrite,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Null when the entry reported no cost, which is not the same as free. */
|
|
125
|
+
function costFrom(usage) {
|
|
126
|
+
const cost = usage?.cost;
|
|
127
|
+
if (!cost || typeof cost.total !== "number")
|
|
128
|
+
return null;
|
|
129
|
+
return {
|
|
130
|
+
input: cost.input ?? 0,
|
|
131
|
+
output: cost.output ?? 0,
|
|
132
|
+
cacheRead: cost.cacheRead ?? 0,
|
|
133
|
+
cacheWrite: cost.cacheWrite ?? 0,
|
|
134
|
+
total: cost.total,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Address a turn by what it is rather than where it was found. Forking a
|
|
139
|
+
* session copies entries verbatim, ids included, so the same turn appears
|
|
140
|
+
* in several files and a naive count bills it more than once.
|
|
141
|
+
*/
|
|
142
|
+
function digestOf(entryId, timestamp, kind, usage) {
|
|
143
|
+
const canonical = JSON.stringify([
|
|
144
|
+
entryId,
|
|
145
|
+
timestamp,
|
|
146
|
+
kind,
|
|
147
|
+
usage?.cost?.total ?? null,
|
|
148
|
+
usage?.totalTokens ?? null,
|
|
149
|
+
]);
|
|
150
|
+
return createHash("sha256")
|
|
151
|
+
.update(canonical)
|
|
152
|
+
.digest("hex")
|
|
153
|
+
.slice(0, DIGEST_CHARS);
|
|
154
|
+
}
|
|
155
|
+
function asRecord(value) {
|
|
156
|
+
return typeof value === "object" && value !== null
|
|
157
|
+
? value
|
|
158
|
+
: null;
|
|
159
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SessionRecord } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Name the repo a working directory belongs to, or nothing when the path
|
|
4
|
+
* names none.
|
|
5
|
+
*
|
|
6
|
+
* A monorepo zone is named by the zone rather than the worktree it was
|
|
7
|
+
* cut into, because two trees of the same zone are the same subject and
|
|
8
|
+
* naming the tree would split one zone's spend across every tree ever
|
|
9
|
+
* cut for it.
|
|
10
|
+
*/
|
|
11
|
+
export declare function repoOf(cwd: string | null): string | null;
|
|
12
|
+
/**
|
|
13
|
+
* Accumulates what a log says about its session as the log is read, so
|
|
14
|
+
* one pass serves both the turns and their attribution.
|
|
15
|
+
*/
|
|
16
|
+
export declare class SessionCollector {
|
|
17
|
+
private readonly sessionId;
|
|
18
|
+
private cwd;
|
|
19
|
+
private quest;
|
|
20
|
+
private first;
|
|
21
|
+
private last;
|
|
22
|
+
constructor(sessionId: string);
|
|
23
|
+
/**
|
|
24
|
+
* Take the working directory from a session's header entry. Every log
|
|
25
|
+
* opens with one, which is what makes attribution complete rather than
|
|
26
|
+
* limited to the quarter of sessions that also name a quest.
|
|
27
|
+
*/
|
|
28
|
+
observeHeader(entry: Record<string, unknown>): void;
|
|
29
|
+
/**
|
|
30
|
+
* Take the working directory and quest a workflow entry names. The
|
|
31
|
+
* last one wins, because a session can be re-pointed at another quest
|
|
32
|
+
* part way through and the later statement is the current one.
|
|
33
|
+
*/
|
|
34
|
+
observeWorkflow(data: Record<string, unknown>): void;
|
|
35
|
+
/** Widen the span to include a billed turn. */
|
|
36
|
+
observeTurn(timestamp: string): void;
|
|
37
|
+
record(): SessionRecord;
|
|
38
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Where a per-host checkout tree begins, as `src/{host}/{owner}/{repo}`. */
|
|
2
|
+
const CHECKOUT_MARKER = "/src/";
|
|
3
|
+
/** Where a monorepo worktree begins, as `world/trees/{tree}/src/{zone}`. */
|
|
4
|
+
const MONOREPO_MARKER = "/world/trees/";
|
|
5
|
+
/** Segments naming a repo under the checkout marker: host, owner, name. */
|
|
6
|
+
const REPO_SEGMENTS = 3;
|
|
7
|
+
/**
|
|
8
|
+
* Name the repo a working directory belongs to, or nothing when the path
|
|
9
|
+
* names none.
|
|
10
|
+
*
|
|
11
|
+
* A monorepo zone is named by the zone rather than the worktree it was
|
|
12
|
+
* cut into, because two trees of the same zone are the same subject and
|
|
13
|
+
* naming the tree would split one zone's spend across every tree ever
|
|
14
|
+
* cut for it.
|
|
15
|
+
*/
|
|
16
|
+
export function repoOf(cwd) {
|
|
17
|
+
if (!cwd)
|
|
18
|
+
return null;
|
|
19
|
+
const monorepo = cwd.indexOf(MONOREPO_MARKER);
|
|
20
|
+
if (monorepo >= 0) {
|
|
21
|
+
const tail = cwd.slice(monorepo + MONOREPO_MARKER.length);
|
|
22
|
+
const zone = tail.split("/src/")[1];
|
|
23
|
+
return zone ? `world/${zone}` : null;
|
|
24
|
+
}
|
|
25
|
+
const checkout = cwd.indexOf(CHECKOUT_MARKER);
|
|
26
|
+
if (checkout >= 0) {
|
|
27
|
+
const parts = cwd
|
|
28
|
+
.slice(checkout + CHECKOUT_MARKER.length)
|
|
29
|
+
.split("/")
|
|
30
|
+
.filter(Boolean);
|
|
31
|
+
if (parts.length >= REPO_SEGMENTS) {
|
|
32
|
+
return parts.slice(0, REPO_SEGMENTS).join("/");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Accumulates what a log says about its session as the log is read, so
|
|
39
|
+
* one pass serves both the turns and their attribution.
|
|
40
|
+
*/
|
|
41
|
+
export class SessionCollector {
|
|
42
|
+
sessionId;
|
|
43
|
+
cwd = null;
|
|
44
|
+
quest = null;
|
|
45
|
+
first = null;
|
|
46
|
+
last = null;
|
|
47
|
+
constructor(sessionId) {
|
|
48
|
+
this.sessionId = sessionId;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Take the working directory from a session's header entry. Every log
|
|
52
|
+
* opens with one, which is what makes attribution complete rather than
|
|
53
|
+
* limited to the quarter of sessions that also name a quest.
|
|
54
|
+
*/
|
|
55
|
+
observeHeader(entry) {
|
|
56
|
+
if (typeof entry.cwd === "string")
|
|
57
|
+
this.cwd = entry.cwd;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Take the working directory and quest a workflow entry names. The
|
|
61
|
+
* last one wins, because a session can be re-pointed at another quest
|
|
62
|
+
* part way through and the later statement is the current one.
|
|
63
|
+
*/
|
|
64
|
+
observeWorkflow(data) {
|
|
65
|
+
if (typeof data.cwd === "string")
|
|
66
|
+
this.cwd = data.cwd;
|
|
67
|
+
if (typeof data.questId === "string")
|
|
68
|
+
this.quest = data.questId;
|
|
69
|
+
}
|
|
70
|
+
/** Widen the span to include a billed turn. */
|
|
71
|
+
observeTurn(timestamp) {
|
|
72
|
+
if (!timestamp)
|
|
73
|
+
return;
|
|
74
|
+
if (!this.first || timestamp < this.first)
|
|
75
|
+
this.first = timestamp;
|
|
76
|
+
if (!this.last || timestamp > this.last)
|
|
77
|
+
this.last = timestamp;
|
|
78
|
+
}
|
|
79
|
+
record() {
|
|
80
|
+
return {
|
|
81
|
+
sessionId: this.sessionId,
|
|
82
|
+
cwd: this.cwd,
|
|
83
|
+
repo: repoOf(this.cwd),
|
|
84
|
+
quest: this.quest,
|
|
85
|
+
firstSeen: this.first,
|
|
86
|
+
lastSeen: this.last,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -21,7 +21,7 @@ export interface RecordOutcome {
|
|
|
21
21
|
readonly duplicates: number;
|
|
22
22
|
}
|
|
23
23
|
/** What a total or a slice may be narrowed to. */
|
|
24
|
-
export type CostDimension = "model" | "session" | "kind" | "day" | "repo" | "quest";
|
|
24
|
+
export type CostDimension = "model" | "session" | "kind" | "day" | "repo" | "quest" | "thinking";
|
|
25
25
|
/** A content-addressed store of billable turns. */
|
|
26
26
|
export interface TurnStore {
|
|
27
27
|
recordTurns(turns: readonly TurnRecord[]): Promise<RecordOutcome>;
|
|
@@ -40,6 +40,8 @@ export interface TurnStore {
|
|
|
40
40
|
paybackReplay(): Promise<PaybackReplay>;
|
|
41
41
|
total(): Promise<LedgerTotal>;
|
|
42
42
|
costBy(dimension: CostDimension): Promise<CostSlice[]>;
|
|
43
|
+
/** Every session the ledger holds, for another store to join to. */
|
|
44
|
+
sessions(): Promise<SessionRecord[]>;
|
|
43
45
|
close(): Promise<void>;
|
|
44
46
|
}
|
|
45
47
|
/**
|
|
@@ -80,6 +80,7 @@ const GROUP_BY = {
|
|
|
80
80
|
// that quietly excluded most of the money.
|
|
81
81
|
repo: "COALESCE(sessions.repo, '')",
|
|
82
82
|
quest: "COALESCE(sessions.quest, '')",
|
|
83
|
+
thinking: "COALESCE(turns.thinking_level, '')",
|
|
83
84
|
};
|
|
84
85
|
/**
|
|
85
86
|
* Open (creating if needed) a turn ledger at the given path. Safe to
|
|
@@ -90,8 +91,22 @@ export async function openTurnStore(dbPath) {
|
|
|
90
91
|
const db = await openDb(dbPath);
|
|
91
92
|
await db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
|
|
92
93
|
await db.exec(SCHEMA);
|
|
94
|
+
await migrate(db);
|
|
93
95
|
return new SqliteTurnStore(db);
|
|
94
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Bring a ledger written at an older shape up to this one. A new ledger
|
|
99
|
+
* is created at the original shape and migrated like any other, so every
|
|
100
|
+
* test that opens a fresh store also exercises the path an existing file
|
|
101
|
+
* meets. Every step is additive, so nothing a ledger holds can be lost by
|
|
102
|
+
* opening it.
|
|
103
|
+
*/
|
|
104
|
+
async function migrate(db) {
|
|
105
|
+
const columns = new Set((await db.all("PRAGMA table_info(turns)")).map((column) => column.name));
|
|
106
|
+
if (!columns.has("thinking_level")) {
|
|
107
|
+
await db.exec("ALTER TABLE turns ADD COLUMN thinking_level TEXT");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
95
110
|
class SqliteTurnStore {
|
|
96
111
|
db;
|
|
97
112
|
constructor(db) {
|
|
@@ -128,6 +143,7 @@ class SqliteTurnStore {
|
|
|
128
143
|
// Seen before, so not billed again, but the sighting is still
|
|
129
144
|
// recorded: deduplicating must not hide that it happened.
|
|
130
145
|
await this.sight(t);
|
|
146
|
+
await this.fillThinkingLevel(t);
|
|
131
147
|
continue;
|
|
132
148
|
}
|
|
133
149
|
inserted += 1;
|
|
@@ -136,8 +152,8 @@ class SqliteTurnStore {
|
|
|
136
152
|
tokens_input, tokens_output, tokens_cache_read,
|
|
137
153
|
tokens_cache_write, tokens_total, cache_write_1h,
|
|
138
154
|
cost_input, cost_output, cost_cache_read, cost_cache_write,
|
|
139
|
-
cost_total, dropped_before, first_kept_entry_id
|
|
140
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
155
|
+
cost_total, dropped_before, first_kept_entry_id, thinking_level
|
|
156
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
141
157
|
t.digest,
|
|
142
158
|
t.entryId,
|
|
143
159
|
t.sessionId,
|
|
@@ -157,6 +173,7 @@ class SqliteTurnStore {
|
|
|
157
173
|
t.cost?.total ?? null,
|
|
158
174
|
t.droppedBefore,
|
|
159
175
|
t.firstKeptEntryId,
|
|
176
|
+
t.thinkingLevel,
|
|
160
177
|
]);
|
|
161
178
|
await this.sight(t);
|
|
162
179
|
}
|
|
@@ -498,9 +515,32 @@ class SqliteTurnStore {
|
|
|
498
515
|
turns: r.turns,
|
|
499
516
|
}));
|
|
500
517
|
}
|
|
518
|
+
async sessions() {
|
|
519
|
+
const rows = await this.db.all(`SELECT session_id, cwd, repo, quest, first_seen, last_seen
|
|
520
|
+
FROM sessions ORDER BY session_id`);
|
|
521
|
+
return rows.map((r) => ({
|
|
522
|
+
sessionId: r.session_id,
|
|
523
|
+
cwd: r.cwd,
|
|
524
|
+
repo: r.repo,
|
|
525
|
+
quest: r.quest,
|
|
526
|
+
firstSeen: r.first_seen,
|
|
527
|
+
lastSeen: r.last_seen,
|
|
528
|
+
}));
|
|
529
|
+
}
|
|
501
530
|
async close() {
|
|
502
531
|
await this.db.close();
|
|
503
532
|
}
|
|
533
|
+
/**
|
|
534
|
+
* Give a held turn the thinking level a later scan learned, when it
|
|
535
|
+
* had none. This is how a ledger indexed before the column existed
|
|
536
|
+
* gets it on the next rescan, without billing anything twice. A level
|
|
537
|
+
* already known is left alone: the same entry cannot have run at two.
|
|
538
|
+
*/
|
|
539
|
+
async fillThinkingLevel(t) {
|
|
540
|
+
if (t.thinkingLevel === null)
|
|
541
|
+
return;
|
|
542
|
+
await this.db.run("UPDATE turns SET thinking_level = ? WHERE digest = ? AND thinking_level IS NULL", [t.thinkingLevel, t.digest]);
|
|
543
|
+
}
|
|
504
544
|
async sight(t) {
|
|
505
545
|
await this.db.run("INSERT OR IGNORE INTO sightings (digest, session_id) VALUES (?, ?)", [t.digest, t.sessionId]);
|
|
506
546
|
}
|
|
@@ -17,6 +17,13 @@ export interface TurnRecord {
|
|
|
17
17
|
readonly kind: TurnKind;
|
|
18
18
|
/** Resolved model id, or empty when the entry did not say. */
|
|
19
19
|
readonly model: string;
|
|
20
|
+
/**
|
|
21
|
+
* The thinking level the turn ran at, as the log last set it before
|
|
22
|
+
* the turn, or null when the log never said. Unknown is not a level:
|
|
23
|
+
* guessing the harness default would put spend on a setting that may
|
|
24
|
+
* not have been in force.
|
|
25
|
+
*/
|
|
26
|
+
readonly thinkingLevel: string | null;
|
|
20
27
|
readonly tokens: RunTokens;
|
|
21
28
|
/** Null when unmetered. Never coerced to zero. */
|
|
22
29
|
readonly cost: RunCost | null;
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
* with web-search-integration.
|
|
12
12
|
*/
|
|
13
13
|
import * as fs from "node:fs";
|
|
14
|
-
import puppeteer from "puppeteer-core";
|
|
15
14
|
/**
|
|
16
15
|
* Default Slack URL to navigate to: the dedicated sign-in entry
|
|
17
16
|
* point, not the marketing homepage. app.slack.com used to be here
|
|
@@ -76,6 +75,9 @@ function findChrome() {
|
|
|
76
75
|
*/
|
|
77
76
|
export async function extractFromBrowser(slackUrl = DEFAULT_SLACK_URL, timeoutMs = DEFAULT_TIMEOUT_MS, onStep) {
|
|
78
77
|
const chromePath = findChrome();
|
|
78
|
+
// Loaded here rather than at import, since this runs once per
|
|
79
|
+
// Slack setup and the module is imported on every start.
|
|
80
|
+
const { default: puppeteer } = await import("puppeteer-core");
|
|
79
81
|
const browser = await puppeteer.launch({
|
|
80
82
|
executablePath: chromePath,
|
|
81
83
|
headless: false,
|
package/dist/web/browser.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* plus a shared in-flight launch promise, means one browser and
|
|
12
12
|
* one profile no matter how many extensions reach for it.
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
14
|
+
import type { Browser, BrowserContext, Page } from "puppeteer-core";
|
|
15
15
|
/** Diagnostic detail teased out of a failed Chrome launch. */
|
|
16
16
|
export interface LaunchFailureInfo {
|
|
17
17
|
exitCode?: number;
|
package/dist/web/browser.js
CHANGED
|
@@ -17,8 +17,15 @@ import * as fs from "node:fs";
|
|
|
17
17
|
import * as os from "node:os";
|
|
18
18
|
import * as path from "node:path";
|
|
19
19
|
import { clearTimeout, setTimeout } from "node:timers";
|
|
20
|
-
import puppeteer from "puppeteer-core";
|
|
21
20
|
import { processGlobal } from "../internal/process-global.js";
|
|
21
|
+
/**
|
|
22
|
+
* puppeteer, loaded when a browser is first launched or joined rather
|
|
23
|
+
* than whenever this module is imported, since most sessions never
|
|
24
|
+
* open one.
|
|
25
|
+
*/
|
|
26
|
+
async function puppeteer() {
|
|
27
|
+
return (await import("puppeteer-core")).default;
|
|
28
|
+
}
|
|
22
29
|
/** How many times to try launching Chrome before giving up. */
|
|
23
30
|
const LAUNCH_ATTEMPTS = 3;
|
|
24
31
|
/** Backoff before each retry, in milliseconds. The array length is
|
|
@@ -507,7 +514,7 @@ async function launchOnce(executablePath) {
|
|
|
507
514
|
// prior attempt cannot poison this one.
|
|
508
515
|
fs.rmSync(profileDir, { recursive: true, force: true });
|
|
509
516
|
fs.mkdirSync(profileDir, { recursive: true });
|
|
510
|
-
const browser = await puppeteer.launch({
|
|
517
|
+
const browser = await (await puppeteer()).launch({
|
|
511
518
|
executablePath,
|
|
512
519
|
headless: true,
|
|
513
520
|
userDataDir: profileDir,
|
|
@@ -594,7 +601,7 @@ export async function connectShared() {
|
|
|
594
601
|
if (!owner?.browserWSEndpoint || !isPidAlive(owner.ownerPid))
|
|
595
602
|
continue;
|
|
596
603
|
try {
|
|
597
|
-
return await puppeteer.connect({
|
|
604
|
+
return await (await puppeteer()).connect({
|
|
598
605
|
browserWSEndpoint: owner.browserWSEndpoint,
|
|
599
606
|
});
|
|
600
607
|
}
|
package/dist/web/reader.js
CHANGED
|
@@ -13,8 +13,6 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import * as path from "node:path";
|
|
16
|
-
import { Defuddle } from "defuddle/node";
|
|
17
|
-
import { JSDOM, VirtualConsole } from "jsdom";
|
|
18
16
|
import { isPidAlive, newPage } from "./browser.js";
|
|
19
17
|
import { injectCookies, isSetUp } from "./cookies/index.js";
|
|
20
18
|
import { BUNDLE_ROOT, diskSink, LEGACY_BUNDLE_ROOTS, sessionDir, } from "./envelope/sink.js";
|
|
@@ -145,8 +143,8 @@ function cleanText(text) {
|
|
|
145
143
|
* Create a jsdom VirtualConsole that suppresses CSS parse warnings
|
|
146
144
|
* without affecting process.stderr globally.
|
|
147
145
|
*/
|
|
148
|
-
function quietVirtualConsole() {
|
|
149
|
-
const vc = new
|
|
146
|
+
function quietVirtualConsole(Console) {
|
|
147
|
+
const vc = new Console();
|
|
150
148
|
vc.on("error", (msg) => {
|
|
151
149
|
if (!msg.includes("Could not parse CSS stylesheet")) {
|
|
152
150
|
console.error(msg);
|
|
@@ -163,7 +161,14 @@ function quietVirtualConsole() {
|
|
|
163
161
|
*/
|
|
164
162
|
async function extractArticle(html, url) {
|
|
165
163
|
try {
|
|
166
|
-
|
|
164
|
+
// jsdom and defuddle load on the first page read rather than at
|
|
165
|
+
// import: jsdom alone is a full browser DOM, over 100 MB.
|
|
166
|
+
const { JSDOM, VirtualConsole } = await import("jsdom");
|
|
167
|
+
const { Defuddle } = await import("defuddle/node");
|
|
168
|
+
const dom = new JSDOM(html, {
|
|
169
|
+
url,
|
|
170
|
+
virtualConsole: quietVirtualConsole(VirtualConsole),
|
|
171
|
+
});
|
|
167
172
|
const result = await Defuddle(dom.window.document, url, {
|
|
168
173
|
markdown: true,
|
|
169
174
|
useAsync: false,
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* Applying, observing and diverging all live here so the one
|
|
8
8
|
* piece of state has one keeper.
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
// The catalogue's own module, rather than puppeteer's root, which
|
|
11
|
+
// would load all of puppeteer to read one table.
|
|
12
|
+
import { KnownDevices } from "puppeteer-core/internal/common/Device.js";
|
|
11
13
|
import { deviceEmulation, noSuchDevice } from "../environment/devices.js";
|
|
12
14
|
import { divergences, ENVIRONMENT_PROBE, mediaFeaturesOf, mergeEmulation, refusedFeature, unsupportedFields, withoutFeature, } from "../environment/index.js";
|
|
13
15
|
/**
|
package/package.json
CHANGED