@saadnvd1/agent-os 0.3.506 → 0.3.508

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.
@@ -1,4 +1,4 @@
1
- import type { CallUsage } from "../usage/calls";
1
+ import type { CallUsage } from "../usage/types";
2
2
  import type { SearchResult } from "./find";
3
3
 
4
4
  /**
@@ -123,11 +123,17 @@ const usd = (x: number, floor: boolean) =>
123
123
  const url = (u: string) =>
124
124
  /^https?:\/\/[^\s<>()[\]`]+$/.test(u) ? `<${u}>` : codeSpan(u);
125
125
 
126
+ // A commit hash is not a secret, so it skips redact(): only a plain hash
127
+ // (or one the log labels as the PR head) is printed, anything else is not.
128
+ const SHA = /^[0-9a-f]{7,40}( \(PR head\))?$/;
129
+ export const commitText = (sha: string | null): string =>
130
+ sha && SHA.test(sha) ? `\`${sha}\`` : "unknown";
131
+
126
132
  function header(d: LogData): string {
127
133
  const start = sqliteMs(d.startedAt);
128
134
  const end = sqliteMs(d.mergedAt);
129
135
  const duration =
130
- start !== null && end !== null ? formatDuration(end - start) : "unknown";
136
+ start !== null && end !== null ? formatDuration(end - start) : null;
131
137
  const rows: [string, string][] = [
132
138
  ["Task", `${codeSpan(d.name)} (${codeSpan(d.id)})`],
133
139
  ["Workspace", d.workspace ? codeSpan(d.workspace) : "none"],
@@ -139,10 +145,10 @@ function header(d: LogData): string {
139
145
  ? `#${d.prNumber}${d.prUrl ? ` ${url(d.prUrl)}` : ""}`
140
146
  : "none recorded",
141
147
  ],
142
- ["Merge commit", d.mergeCommit ? codeSpan(d.mergeCommit) : "not recorded"],
148
+ ["Merge commit", commitText(d.mergeCommit)],
143
149
  ["Started", when(d.startedAt)],
144
- ["Merged", when(d.mergedAt)],
145
- ["Duration", duration],
150
+ ["Merged", d.mergedAt ? when(d.mergedAt) : "unknown"],
151
+ ...(duration ? [["Duration", duration] as [string, string]] : []),
146
152
  ];
147
153
  return rows.map(([k, v]) => `- **${k}:** ${v}`).join("\n");
148
154
  }
@@ -140,3 +140,8 @@ export function writeLogFileTo(
140
140
 
141
141
  export const writeLogFile = (t: FileTarget, markdown: string): string =>
142
142
  writeLogFileTo(t, markdown).file;
143
+
144
+ // Rewrites a log's own file in place (never creates one, never moves it).
145
+ export function overwriteLogFile(file: string, markdown: string): void {
146
+ if (fs.existsSync(file)) fs.writeFileSync(file, markdown, { mode: 0o600 });
147
+ }
@@ -14,10 +14,22 @@ import { db, type Session } from "../db";
14
14
  import { getProject } from "../projects";
15
15
  import { run } from "../tasks/gh";
16
16
  import { buildLogMarkdown, sqliteMs } from "./build";
17
- import { writeLogFileTo, type Destination } from "./folder";
17
+ import {
18
+ overwriteLogFile,
19
+ destination,
20
+ writeLogFileTo,
21
+ type Destination,
22
+ } from "./folder";
18
23
  import { commitLogs } from "./hq";
19
24
  import { gatherLogData, readPlanFile } from "./gather";
20
- import { hasLog, insertLog, setFilePath } from "./store";
25
+ import {
26
+ getLog,
27
+ hasLog,
28
+ headerOf,
29
+ insertLog,
30
+ setFilePath,
31
+ updateLog,
32
+ } from "./store";
21
33
 
22
34
  export * from "./store";
23
35
  export { buildLogMarkdown } from "./build";
@@ -43,6 +55,10 @@ export interface CaptureOpts {
43
55
  // Given, the file is handed over instead of committed: a batch commits
44
56
  // once at its end.
45
57
  onFile?: (file: string, hq: NonNullable<Destination["hq"]>) => void;
58
+ // Regenerate a log that exists: update its row and file in place.
59
+ rewrite?: boolean;
60
+ // With rewrite: report whether the log would change, write nothing.
61
+ dryRun?: boolean;
46
62
  }
47
63
 
48
64
  function eligible(s: Session | undefined): s is Session {
@@ -55,31 +71,51 @@ function eligible(s: Session | undefined): s is Session {
55
71
  );
56
72
  }
57
73
 
58
- async function mergeCommitOf(s: Session): Promise<string | null> {
74
+ // GitHub's ISO time as SQLite's UTC "YYYY-MM-DD HH:MM:SS"; null when absent.
75
+ export function ghTimeToSqlite(iso: unknown): string | null {
76
+ if (typeof iso !== "string") return null;
77
+ const ms = Date.parse(iso);
78
+ return Number.isNaN(ms)
79
+ ? null
80
+ : new Date(ms).toISOString().replace("T", " ").slice(0, 19);
81
+ }
82
+
83
+ async function mergeInfoOf(
84
+ s: Session
85
+ ): Promise<{ commit: string | null; mergedAt: string | null }> {
86
+ const none = { commit: null, mergedAt: null };
59
87
  const project = s.project_id ? getProject(s.project_id) : undefined;
60
88
  if (!project || !s.pr_number || process.env.AGENTOS_SESSION_LOGS_NO_GH)
61
- return null;
89
+ return none;
62
90
  try {
63
91
  const out = await run(
64
92
  "gh",
65
- [
66
- "pr",
67
- "view",
68
- String(s.pr_number),
69
- "--json",
70
- "mergeCommit",
71
- "-q",
72
- ".mergeCommit.oid",
73
- ],
93
+ ["pr", "view", String(s.pr_number), "--json", "mergeCommit,mergedAt"],
74
94
  project.working_directory.replace(/^~/, os.homedir()),
75
95
  15_000
76
96
  );
77
- return out.trim() || null;
97
+ const j = JSON.parse(out) as {
98
+ mergeCommit?: { oid?: string } | null;
99
+ mergedAt?: string | null;
100
+ };
101
+ return {
102
+ commit: j.mergeCommit?.oid?.trim() || null,
103
+ mergedAt: ghTimeToSqlite(j.mergedAt),
104
+ };
78
105
  } catch {
79
- return null;
106
+ return none;
80
107
  }
81
108
  }
82
109
 
110
+ const planSection = (md: string) =>
111
+ /^## Plan\n[\s\S]*?(?=\n## Timeline\n)/m.exec(md)?.[0] ?? null;
112
+
113
+ function keepPlan(fresh: string, old: string): string {
114
+ const was = planSection(old);
115
+ const now = planSection(fresh);
116
+ return was && now ? fresh.replace(now, () => was) : fresh;
117
+ }
118
+
83
119
  export async function writeLog(
84
120
  id: string,
85
121
  opts: CaptureOpts & { plan?: string | null } = {}
@@ -87,20 +123,77 @@ export async function writeLog(
87
123
  const row = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
88
124
  | Session
89
125
  | undefined;
90
- if (!eligible(row) || hasLog(id)) return false;
91
- const s = { ...row, merged_at: row.merged_at ?? sqliteNow() };
92
- const mergeCommit = opts.noGh ? null : await mergeCommitOf(s);
126
+ const existing = getLog(id);
127
+ if (!eligible(row) || (existing && !opts.rewrite)) return false;
128
+ const info = opts.noGh
129
+ ? { commit: null, mergedAt: null }
130
+ : await mergeInfoOf(row);
131
+ const mergeCommit = info.commit;
132
+ const s = { ...row, merged_at: row.merged_at ?? info.mergedAt };
133
+ const kept = existing ? headerOf(existing) : null;
93
134
  const data = gatherLogData(s, {
94
135
  plan: opts.plan ?? null,
95
- planKept: opts.planKept ?? true,
136
+ planKept: kept ? kept.planKept : (opts.planKept ?? true),
96
137
  head: opts.head ?? null,
97
138
  mergeCommit: mergeCommit ?? (opts.head ? `${opts.head} (PR head)` : null),
98
139
  });
99
- const markdown = buildLogMarkdown(data);
140
+ let markdown = buildLogMarkdown(data);
141
+ // The plan file went with the worktree: keep the plan the log has.
142
+ if (existing && kept?.hasPlan)
143
+ markdown = keepPlan(markdown, existing.markdown);
100
144
  const start = sqliteMs(data.startedAt);
101
145
  const end = sqliteMs(data.mergedAt);
102
146
  const workspaceId =
103
147
  s.workspace_id ?? getProject(s.project_id ?? "")?.workspace_id ?? null;
148
+ if (existing) {
149
+ const changed = existing.markdown !== markdown;
150
+ if (opts.dryRun || !changed) return changed;
151
+ // Only the generated text changes; the row, its id and its file stay.
152
+ updateLog(id, markdown, {
153
+ id,
154
+ name: data.name,
155
+ workspace: data.workspace,
156
+ project: data.project,
157
+ branch: data.branch,
158
+ prNumber: data.prNumber,
159
+ prUrl: data.prUrl,
160
+ mergeCommit: data.mergeCommit,
161
+ startedAt: data.startedAt,
162
+ mergedAt: data.mergedAt,
163
+ durationMs: start !== null && end !== null ? end - start : null,
164
+ costUsd: data.usage.reduce((t, u) => t + u.costUsd, 0),
165
+ planKept: data.planKept,
166
+ hasPlan: kept?.hasPlan ?? data.plan !== null,
167
+ });
168
+ if (existing.file_path) {
169
+ try {
170
+ overwriteLogFile(existing.file_path, markdown);
171
+ const { hq } = destination({
172
+ workspace: data.workspace,
173
+ workspaceId,
174
+ project: data.project,
175
+ projectId: s.project_id,
176
+ name: data.name,
177
+ id,
178
+ day: "",
179
+ });
180
+ if (hq && existing.file_path.startsWith(hq.dir)) {
181
+ if (opts.onFile) opts.onFile(existing.file_path, hq);
182
+ else
183
+ await commitLogs(
184
+ hq.dir,
185
+ [existing.file_path],
186
+ `session log rewritten: ${data.name.replace(/\s+/g, " ").trim().slice(0, 100)}`,
187
+ hq.visibility
188
+ );
189
+ }
190
+ } catch (error) {
191
+ console.error(`[session-logs] file for ${id}:`, error);
192
+ }
193
+ }
194
+ return true;
195
+ }
196
+ if (opts.dryRun) return true;
104
197
  const stored = insertLog({
105
198
  sessionId: id,
106
199
  projectId: s.project_id,
@@ -70,6 +70,17 @@ export function insertLog(row: {
70
70
  );
71
71
  }
72
72
 
73
+ // Replaces the text and header of a log that exists; the row stays.
74
+ export function updateLog(
75
+ sessionId: string,
76
+ markdown: string,
77
+ header: Omit<LogHeader, "createdAt" | "filePath">
78
+ ): void {
79
+ db.prepare(
80
+ `UPDATE session_logs SET markdown = ?, data = ? WHERE session_id = ?`
81
+ ).run(markdown, JSON.stringify(header), sessionId);
82
+ }
83
+
73
84
  export function setFilePath(sessionId: string, filePath: string): void {
74
85
  db.prepare(`UPDATE session_logs SET file_path = ? WHERE session_id = ?`).run(
75
86
  filePath,
@@ -8,46 +8,9 @@
8
8
  import { db } from "../db";
9
9
  import { ANTHROPIC, sessionRole } from "../models/roles";
10
10
  import { ctxOf, weighted, type TokenRow } from "./weights";
11
+ import type { CallKind, CallRow, CallSource, CallUsage } from "./types";
11
12
 
12
- export type CallKind = "main" | "sub" | "review" | "gate" | "helper";
13
- export type CallSource = "live" | "transcript" | "check_run" | "helper";
14
-
15
- export interface CallRow {
16
- callKey: string;
17
- at: number;
18
- sessionId: string | null;
19
- taskId: string | null;
20
- workspaceId: string | null;
21
- projectId: string | null;
22
- sessionName: string | null;
23
- role: string | null;
24
- kind: CallKind;
25
- agentType: string | null;
26
- model: string | null;
27
- provider: string;
28
- input: number;
29
- cacheRead: number;
30
- cacheWrite5m: number;
31
- cacheWrite1h: number;
32
- output: number;
33
- ctx: number | null;
34
- source: CallSource;
35
- }
36
-
37
- // What a driver reports for one response, before it's tied to a session.
38
- export type CallUsage = Pick<
39
- CallRow,
40
- | "callKey"
41
- | "at"
42
- | "kind"
43
- | "agentType"
44
- | "model"
45
- | "input"
46
- | "cacheRead"
47
- | "cacheWrite5m"
48
- | "cacheWrite1h"
49
- | "output"
50
- >;
13
+ export type { CallKind, CallRow, CallSource, CallUsage };
51
14
 
52
15
  interface RawUsage {
53
16
  input_tokens?: number;
@@ -0,0 +1,41 @@
1
+ /** Pure usage-ledger types, shared with the mobile app: no imports, nothing server-only. */
2
+
3
+ export type CallKind = "main" | "sub" | "review" | "gate" | "helper";
4
+ export type CallSource = "live" | "transcript" | "check_run" | "helper";
5
+
6
+ export interface CallRow {
7
+ callKey: string;
8
+ at: number;
9
+ sessionId: string | null;
10
+ taskId: string | null;
11
+ workspaceId: string | null;
12
+ projectId: string | null;
13
+ sessionName: string | null;
14
+ role: string | null;
15
+ kind: CallKind;
16
+ agentType: string | null;
17
+ model: string | null;
18
+ provider: string;
19
+ input: number;
20
+ cacheRead: number;
21
+ cacheWrite5m: number;
22
+ cacheWrite1h: number;
23
+ output: number;
24
+ ctx: number | null;
25
+ source: CallSource;
26
+ }
27
+
28
+ // What a driver reports for one response, before it's tied to a session.
29
+ export type CallUsage = Pick<
30
+ CallRow,
31
+ | "callKey"
32
+ | "at"
33
+ | "kind"
34
+ | "agentType"
35
+ | "model"
36
+ | "input"
37
+ | "cacheRead"
38
+ | "cacheWrite5m"
39
+ | "cacheWrite1h"
40
+ | "output"
41
+ >;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.506",
3
+ "version": "0.3.508",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -1,18 +1,21 @@
1
1
  /**
2
2
  * Writes session logs for tasks that merged before logs existed, from what
3
3
  * the database still holds. Their plan files went with their worktrees, so
4
- * each says "plan not kept". Idempotent: a task with a log is skipped.
4
+ * each says "plan not kept". Idempotent: a task with a log is skipped, unless --rewrite.
5
5
  *
6
- * npx tsx scripts/backfill-session-logs.ts --db <path to agent-os.db> [--no-gh] [--dry] [--reexport]
6
+ * npx tsx scripts/backfill-session-logs.ts --db <path to agent-os.db> [--no-gh] [--dry] [--dry-run] [--rewrite] [--reexport]
7
7
  *
8
8
  * --db is required so a run never lands on a database by accident. Files go
9
9
  * where new logs go: a project's folder, else the workspace's HQ repo (one
10
10
  * commit per HQ at the end of the run), else ~/.agent-os/session-logs; run
11
11
  * with HOME set elsewhere to keep a trial away from the real folder.
12
- * --no-gh skips asking GitHub for each merge commit.
12
+ * --no-gh skips asking GitHub for each merge commit and time.
13
13
  * --reexport writes the files of logs already in the database to where they
14
14
  * go now, from the stored markdown (no rebuild, no model), and writes no new
15
15
  * logs. Old files are left in place.
16
+ * --rewrite regenerates logs that exist (row and file updated in place, a
17
+ * kept plan stays); no model is called, only the one gh call per PR.
18
+ * --dry-run prints how many logs would be written or change, and writes none.
16
19
  */
17
20
 
18
21
  const args = process.argv.slice(2);
@@ -21,7 +24,7 @@ const dbPath = args[args.indexOf("--db") + 1];
21
24
 
22
25
  if (!flag("--db") || !dbPath || dbPath.startsWith("--")) {
23
26
  console.error(
24
- "usage: backfill-session-logs.ts --db <path> [--no-gh] [--dry] [--reexport]"
27
+ "usage: backfill-session-logs.ts --db <path> [--no-gh] [--dry] [--dry-run] [--rewrite] [--reexport]"
25
28
  );
26
29
  process.exit(2);
27
30
  }
@@ -50,17 +53,15 @@ async function main() {
50
53
  ORDER BY COALESCE(merged_at, updated_at)`
51
54
  )
52
55
  .all() as { id: string; name: string }[];
53
- const todo = rows.filter((r) => !hasLog(r.id));
56
+ const rewrite = flag("--rewrite");
57
+ const dry = flag("--dry") || flag("--dry-run");
58
+ const todo = rewrite ? rows : rows.filter((r) => !hasLog(r.id));
54
59
  console.log(
55
- `${rows.length} merged tasks, ${rows.length - todo.length} already logged, ${todo.length} to write${flag("--dry") ? " (dry run)" : ""}`
60
+ `${rows.length} merged tasks, ${rows.length - rows.filter((r) => !hasLog(r.id)).length} already logged, ${todo.length} to ${rewrite ? "regenerate" : "write"}${dry ? " (dry run)" : ""}`
56
61
  );
57
62
  let written = 0;
58
63
  const byHq = new Map<string, { files: string[]; visibility: Visibility }>();
59
64
  for (const r of todo) {
60
- if (flag("--dry")) {
61
- console.log(` would log ${r.name} (${r.id.slice(0, 8)})`);
62
- continue;
63
- }
64
65
  try {
65
66
  const ok = await writeLog(r.id, {
66
67
  planKept: false,
@@ -73,10 +74,12 @@ async function main() {
73
74
  e.files.push(file);
74
75
  byHq.set(hq.dir, e);
75
76
  },
77
+ rewrite,
78
+ dryRun: dry,
76
79
  });
77
80
  if (ok) written++;
78
81
  console.log(
79
- ` ${ok ? "logged" : "skipped"} ${r.name} (${r.id.slice(0, 8)})`
82
+ ` ${ok ? (dry ? "would change" : rewrite ? "rewrote" : "logged") : "skipped"} ${r.name} (${r.id.slice(0, 8)})`
80
83
  );
81
84
  } catch (error) {
82
85
  console.error(` FAILED ${r.name} (${r.id.slice(0, 8)}):`, error);
@@ -89,7 +92,9 @@ async function main() {
89
92
  `session logs: ${files.length} backfilled`,
90
93
  visibility
91
94
  );
92
- console.log(`wrote ${written} logs`);
95
+ console.log(
96
+ `${dry ? `${written} logs would change` : `wrote ${written} logs`}`
97
+ );
93
98
  }
94
99
 
95
100
  main().then(