@unpolarize/code-sessions 0.3.0 → 0.5.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.
@@ -141,6 +141,7 @@ Commands:
141
141
  index (Re)build the internal SQLite index from the git store
142
142
  query List recent sessions from the index [--limit N] [--agent X]
143
143
  usage Aggregated token/cost usage (totals/by-agent/by-day) [--json]
144
+ graph Sessions \xD7 topics graph (nodes + edges) [--json]
144
145
  search Full-text search session turns <text> [--limit N]
145
146
  fork Fork a session at a turn ("git for sessions") <session-id> --at N [--id X]
146
147
  analytics Compute MVP-2 rollups + digest into analytics/
@@ -633,17 +634,32 @@ var GitStore = class {
633
634
  isRepo() {
634
635
  return existsSync5(join3(this.dir, ".git"));
635
636
  }
636
- /** Initialize the store repo (idempotent): git init, scaffolding files, remote. */
637
+ /** Initialize the store repo (idempotent): git init, connect remote (adopting
638
+ * its existing history on a fresh clone so a second machine continues the same
639
+ * store instead of forking), then write scaffolding files if still absent. */
637
640
  init() {
638
641
  if (!this.isRepo()) {
639
642
  const r = spawnSync("git", ["init", "-b", "main", this.dir], { encoding: "utf8" });
640
643
  if (r.status !== 0) throw new Error(`git init failed: ${r.stderr}`);
641
644
  }
645
+ if (this.opts.remote) {
646
+ this.ensureRemote(this.opts.remote);
647
+ this.adoptRemoteIfEmpty();
648
+ }
642
649
  const giPath = join3(this.dir, ".gitignore");
643
650
  if (!existsSync5(giPath)) writeFileSync3(giPath, GITIGNORE);
644
651
  const gaPath = join3(this.dir, ".gitattributes");
645
652
  if (!existsSync5(gaPath)) writeFileSync3(gaPath, GITATTRIBUTES);
646
- if (this.opts.remote) this.ensureRemote(this.opts.remote);
653
+ }
654
+ /** When this clone has no commits yet, pull the remote's existing store so we
655
+ * continue its history (multi-machine) rather than starting a divergent one.
656
+ * No-op for an empty/unreachable remote. */
657
+ adoptRemoteIfEmpty() {
658
+ if (this.run(["rev-parse", "--verify", "HEAD"]).ok) return;
659
+ if (!this.run(["fetch", "origin"]).ok) return;
660
+ if (!this.run(["rev-parse", "--verify", "origin/main"]).ok) return;
661
+ this.run(["reset", "--hard", "origin/main"]);
662
+ this.run(["branch", "--set-upstream-to=origin/main", "main"]);
647
663
  }
648
664
  ensureRemote(remote) {
649
665
  const existing = this.run(["remote", "get-url", "origin"]);
@@ -2157,6 +2173,41 @@ var SessionIndex = class {
2157
2173
  ).all(like, like, limit);
2158
2174
  return rows.map((r) => this.rowToIndex(r));
2159
2175
  }
2176
+ /** Graph of sessions clustered by topic (+ fork lineage where known). */
2177
+ graphData() {
2178
+ const rows = this.db.prepare("SELECT session_id, agent, topic, intent, cost_usd, title FROM session ORDER BY started_at DESC").all();
2179
+ const nodes = [];
2180
+ const edges = [];
2181
+ const topicNodes = /* @__PURE__ */ new Map();
2182
+ const topicId = (t) => {
2183
+ let id = topicNodes.get(t);
2184
+ if (!id) {
2185
+ id = `topic:${topicNodes.size}`;
2186
+ topicNodes.set(t, id);
2187
+ nodes.push({ id, kind: "topic", label: t, sessions: 0, cost_usd: 0 });
2188
+ }
2189
+ return id;
2190
+ };
2191
+ for (const r of rows) {
2192
+ const sid = `s:${r.session_id}`;
2193
+ nodes.push({
2194
+ id: sid,
2195
+ kind: "session",
2196
+ label: (r.topic || r.title || r.session_id).slice(0, 48),
2197
+ agent: r.agent,
2198
+ intent: r.intent ?? null,
2199
+ cost_usd: r.cost_usd,
2200
+ sessions: 1
2201
+ });
2202
+ const topic = (r.topic || r.intent || "misc").toString();
2203
+ const tid = topicId(topic);
2204
+ edges.push({ from: sid, to: tid, kind: "has-topic" });
2205
+ const tn = nodes.find((n) => n.id === tid);
2206
+ tn.sessions += 1;
2207
+ tn.cost_usd += r.cost_usd;
2208
+ }
2209
+ return { nodes, edges };
2210
+ }
2160
2211
  /** Aggregated usage for a Usage panel: totals, by agent, by day, by project, top cost. */
2161
2212
  usageSummary(opts = {}) {
2162
2213
  const rows = this.db.prepare(
@@ -2462,7 +2513,17 @@ function cmdInit(cfg) {
2462
2513
  if (!existsSync16(configPath)) {
2463
2514
  writeFileSync9(
2464
2515
  configPath,
2465
- `${JSON.stringify({ insights: cfg.insights, batch: cfg.batch, hygiene: cfg.hygiene }, null, 2)}
2516
+ `${JSON.stringify(
2517
+ {
2518
+ insights: cfg.insights,
2519
+ batch: cfg.batch,
2520
+ hygiene: cfg.hygiene,
2521
+ // persist the remote so the daemon/CLI keep pushing here without flags
2522
+ git: { ...cfg.git.remote ? { remote: cfg.git.remote } : {}, autoPush: cfg.git.autoPush }
2523
+ },
2524
+ null,
2525
+ 2
2526
+ )}
2466
2527
  `
2467
2528
  );
2468
2529
  }
@@ -2605,6 +2666,19 @@ function cmdFork(cfg, opts) {
2605
2666
  return { code: 1, output: `fork failed: ${e instanceof Error ? e.message : String(e)}` };
2606
2667
  }
2607
2668
  }
2669
+ function cmdGraph(cfg, opts = {}) {
2670
+ syncIndex(cfg);
2671
+ const index = new SessionIndex(cfg.indexPath);
2672
+ try {
2673
+ const g = index.graphData();
2674
+ if (opts.json) return { code: 0, output: JSON.stringify(g) };
2675
+ const topics = g.nodes.filter((n) => n.kind === "topic").length;
2676
+ const sessions = g.nodes.filter((n) => n.kind === "session").length;
2677
+ return { code: 0, output: `graph: ${sessions} sessions across ${topics} topics, ${g.edges.length} edges` };
2678
+ } finally {
2679
+ index.close();
2680
+ }
2681
+ }
2608
2682
  function cmdUsage(cfg, opts = {}) {
2609
2683
  syncIndex(cfg);
2610
2684
  const index = new SessionIndex(cfg.indexPath);
@@ -2794,6 +2868,7 @@ export {
2794
2868
  cmdDoctor,
2795
2869
  cmdExport,
2796
2870
  cmdFork,
2871
+ cmdGraph,
2797
2872
  cmdUsage,
2798
2873
  cmdInstallSkills,
2799
2874
  cmdIndex,
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  cmdDoctor,
6
6
  cmdExport,
7
7
  cmdFork,
8
+ cmdGraph,
8
9
  cmdIndex,
9
10
  cmdInit,
10
11
  cmdInstallHooks,
@@ -23,7 +24,7 @@ import {
23
24
  parseFlags,
24
25
  readStdin,
25
26
  startDaemon
26
- } from "./chunk-ON3CPW4C.js";
27
+ } from "./chunk-Y7DRROK7.js";
27
28
 
28
29
  // src/analytics/command.ts
29
30
  import { mkdirSync, writeFileSync } from "fs";
@@ -260,6 +261,9 @@ async function main(argv) {
260
261
  case "usage":
261
262
  emit(cmdUsage(cfg, { json: flags.json === true }));
262
263
  break;
264
+ case "graph":
265
+ emit(cmdGraph(cfg, { json: flags.json === true }));
266
+ break;
263
267
  case "query":
264
268
  emit(
265
269
  cmdQuery(cfg, {
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  cmdDoctor,
21
21
  cmdExport,
22
22
  cmdFork,
23
+ cmdGraph,
23
24
  cmdIndex,
24
25
  cmdInit,
25
26
  cmdInstallHooks,
@@ -92,7 +93,7 @@ import {
92
93
  writeBlobFile,
93
94
  writeImportedSession,
94
95
  writeTurnFile
95
- } from "./chunk-ON3CPW4C.js";
96
+ } from "./chunk-Y7DRROK7.js";
96
97
  export {
97
98
  CaptureEngine,
98
99
  DEFAULT_HOOK_EVENTS,
@@ -115,6 +116,7 @@ export {
115
116
  cmdDoctor,
116
117
  cmdExport,
117
118
  cmdFork,
119
+ cmdGraph,
118
120
  cmdIndex,
119
121
  cmdInit,
120
122
  cmdInstallHooks,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unpolarize/code-sessions",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Headless, event-driven cross-agent session capture agent (daemon + CLI)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,7 +25,7 @@
25
25
  "build": "tsup src/index.ts src/cli.ts --format esm --clean --out-dir dist"
26
26
  },
27
27
  "dependencies": {
28
- "@unpolarize/code-sessions-schema": "^0.3.0",
28
+ "@unpolarize/code-sessions-schema": "^0.5.0",
29
29
  "zod": "^3.23.8"
30
30
  }
31
31
  }
package/src/cli.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  cmdDoctor,
6
6
  cmdExport,
7
7
  cmdFork,
8
+ cmdGraph,
8
9
  cmdIndex,
9
10
  cmdInit,
10
11
  cmdInstallHooks,
@@ -74,6 +75,9 @@ export async function main(argv: string[]): Promise<void> {
74
75
  case 'usage':
75
76
  emit(cmdUsage(cfg, { json: flags.json === true }));
76
77
  break;
78
+ case 'graph':
79
+ emit(cmdGraph(cfg, { json: flags.json === true }));
80
+ break;
77
81
  case 'query':
78
82
  emit(
79
83
  cmdQuery(cfg, {
package/src/cliargs.ts CHANGED
@@ -59,6 +59,7 @@ Commands:
59
59
  index (Re)build the internal SQLite index from the git store
60
60
  query List recent sessions from the index [--limit N] [--agent X]
61
61
  usage Aggregated token/cost usage (totals/by-agent/by-day) [--json]
62
+ graph Sessions × topics graph (nodes + edges) [--json]
62
63
  search Full-text search session turns <text> [--limit N]
63
64
  fork Fork a session at a turn ("git for sessions") <session-id> --at N [--id X]
64
65
  analytics Compute MVP-2 rollups + digest into analytics/
package/src/commands.ts CHANGED
@@ -59,7 +59,17 @@ export function cmdInit(cfg: CodeSessionsConfig): CommandResult {
59
59
  if (!existsSync(configPath)) {
60
60
  writeFileSync(
61
61
  configPath,
62
- `${JSON.stringify({ insights: cfg.insights, batch: cfg.batch, hygiene: cfg.hygiene }, null, 2)}\n`,
62
+ `${JSON.stringify(
63
+ {
64
+ insights: cfg.insights,
65
+ batch: cfg.batch,
66
+ hygiene: cfg.hygiene,
67
+ // persist the remote so the daemon/CLI keep pushing here without flags
68
+ git: { ...(cfg.git.remote ? { remote: cfg.git.remote } : {}), autoPush: cfg.git.autoPush },
69
+ },
70
+ null,
71
+ 2,
72
+ )}\n`,
63
73
  );
64
74
  }
65
75
  git.commit('init store');
@@ -234,6 +244,21 @@ export function cmdFork(
234
244
  }
235
245
  }
236
246
 
247
+ /** Sessions × topics graph (nodes + edges) from the CS index. JSON for the graph view. */
248
+ export function cmdGraph(cfg: CodeSessionsConfig, opts: { json?: boolean } = {}): CommandResult {
249
+ syncIndex(cfg);
250
+ const index = new SessionIndex(cfg.indexPath);
251
+ try {
252
+ const g = index.graphData();
253
+ if (opts.json) return { code: 0, output: JSON.stringify(g) };
254
+ const topics = g.nodes.filter((n) => n.kind === 'topic').length;
255
+ const sessions = g.nodes.filter((n) => n.kind === 'session').length;
256
+ return { code: 0, output: `graph: ${sessions} sessions across ${topics} topics, ${g.edges.length} edges` };
257
+ } finally {
258
+ index.close();
259
+ }
260
+ }
261
+
237
262
  /** Aggregated usage from the CS index (totals/byAgent/byDay/byProject/topByCost). */
238
263
  export function cmdUsage(cfg: CodeSessionsConfig, opts: { json?: boolean } = {}): CommandResult {
239
264
  syncIndex(cfg); // ensure the index reflects the current store
@@ -116,6 +116,25 @@ describe('SessionIndex', () => {
116
116
  }
117
117
  });
118
118
 
119
+ it('builds a sessions×topics graph', () => {
120
+ const idx = new SessionIndex(':memory:');
121
+ try {
122
+ idx.upsertSession(env('a', 'claude-code'), { ...src, topic: 'fix parser' });
123
+ idx.upsertSession(env('b', 'grok'), { ...src, source_path: '/s/b.json', topic: 'fix parser' });
124
+ idx.upsertSession(env('c', 'codex'), { ...src, source_path: '/s/c.json', topic: 'add feature' });
125
+ const g = idx.graphData();
126
+ const topics = g.nodes.filter((n) => n.kind === 'topic');
127
+ const sessions = g.nodes.filter((n) => n.kind === 'session');
128
+ expect(sessions).toHaveLength(3);
129
+ expect(topics).toHaveLength(2); // "fix parser" + "add feature"
130
+ expect(g.edges).toHaveLength(3); // each session -> its topic
131
+ const fixParser = topics.find((t) => t.label === 'fix parser')!;
132
+ expect(fixParser.sessions).toBe(2);
133
+ } finally {
134
+ idx.close();
135
+ }
136
+ });
137
+
119
138
  it('filters by agent and deletes sessions (cascade turns)', () => {
120
139
  const idx = new SessionIndex(':memory:');
121
140
  try {
@@ -51,6 +51,25 @@ export interface UsageBucket {
51
51
  cost_usd: number;
52
52
  }
53
53
 
54
+ export interface GraphNode {
55
+ id: string;
56
+ kind: 'session' | 'topic';
57
+ label: string;
58
+ agent?: string;
59
+ intent?: string | null;
60
+ cost_usd: number;
61
+ sessions: number;
62
+ }
63
+ export interface GraphEdge {
64
+ from: string;
65
+ to: string;
66
+ kind: 'has-topic' | 'forked-from';
67
+ }
68
+ export interface GraphData {
69
+ nodes: GraphNode[];
70
+ edges: GraphEdge[];
71
+ }
72
+
54
73
  export interface UsageSummary {
55
74
  totals: { sessions: number; input_tokens: number; output_tokens: number; cost_usd: number };
56
75
  byAgent: Record<string, UsageBucket>;
@@ -308,6 +327,44 @@ export class SessionIndex {
308
327
  return (rows as any[]).map((r) => this.rowToIndex(r));
309
328
  }
310
329
 
330
+ /** Graph of sessions clustered by topic (+ fork lineage where known). */
331
+ graphData(): GraphData {
332
+ const rows = this.db
333
+ .prepare('SELECT session_id, agent, topic, intent, cost_usd, title FROM session ORDER BY started_at DESC')
334
+ .all() as any[];
335
+ const nodes: GraphNode[] = [];
336
+ const edges: GraphEdge[] = [];
337
+ const topicNodes = new Map<string, string>(); // topic -> node id
338
+ const topicId = (t: string) => {
339
+ let id = topicNodes.get(t);
340
+ if (!id) {
341
+ id = `topic:${topicNodes.size}`;
342
+ topicNodes.set(t, id);
343
+ nodes.push({ id, kind: 'topic', label: t, sessions: 0, cost_usd: 0 });
344
+ }
345
+ return id;
346
+ };
347
+ for (const r of rows) {
348
+ const sid = `s:${r.session_id}`;
349
+ nodes.push({
350
+ id: sid,
351
+ kind: 'session',
352
+ label: (r.topic || r.title || r.session_id).slice(0, 48),
353
+ agent: r.agent,
354
+ intent: r.intent ?? null,
355
+ cost_usd: r.cost_usd,
356
+ sessions: 1,
357
+ });
358
+ const topic = (r.topic || r.intent || 'misc').toString();
359
+ const tid = topicId(topic);
360
+ edges.push({ from: sid, to: tid, kind: 'has-topic' });
361
+ const tn = nodes.find((n) => n.id === tid)!;
362
+ tn.sessions += 1;
363
+ tn.cost_usd += r.cost_usd;
364
+ }
365
+ return { nodes, edges };
366
+ }
367
+
311
368
  /** Aggregated usage for a Usage panel: totals, by agent, by day, by project, top cost. */
312
369
  usageSummary(opts: { days?: number; topN?: number } = {}): UsageSummary {
313
370
  const rows = this.db
package/src/store/git.ts CHANGED
@@ -58,17 +58,35 @@ export class GitStore {
58
58
  return existsSync(join(this.dir, '.git'));
59
59
  }
60
60
 
61
- /** Initialize the store repo (idempotent): git init, scaffolding files, remote. */
61
+ /** Initialize the store repo (idempotent): git init, connect remote (adopting
62
+ * its existing history on a fresh clone so a second machine continues the same
63
+ * store instead of forking), then write scaffolding files if still absent. */
62
64
  init(): void {
63
65
  if (!this.isRepo()) {
64
66
  const r = spawnSync('git', ['init', '-b', 'main', this.dir], { encoding: 'utf8' });
65
67
  if (r.status !== 0) throw new Error(`git init failed: ${r.stderr}`);
66
68
  }
69
+ // Connect + adopt remote BEFORE writing scaffolding, so an adopted checkout
70
+ // doesn't collide with untracked .gitignore/.gitattributes we'd write.
71
+ if (this.opts.remote) {
72
+ this.ensureRemote(this.opts.remote);
73
+ this.adoptRemoteIfEmpty();
74
+ }
67
75
  const giPath = join(this.dir, '.gitignore');
68
76
  if (!existsSync(giPath)) writeFileSync(giPath, GITIGNORE);
69
77
  const gaPath = join(this.dir, '.gitattributes');
70
78
  if (!existsSync(gaPath)) writeFileSync(gaPath, GITATTRIBUTES);
71
- if (this.opts.remote) this.ensureRemote(this.opts.remote);
79
+ }
80
+
81
+ /** When this clone has no commits yet, pull the remote's existing store so we
82
+ * continue its history (multi-machine) rather than starting a divergent one.
83
+ * No-op for an empty/unreachable remote. */
84
+ private adoptRemoteIfEmpty(): void {
85
+ if (this.run(['rev-parse', '--verify', 'HEAD']).ok) return; // already has local history
86
+ if (!this.run(['fetch', 'origin']).ok) return; // empty or unreachable remote
87
+ if (!this.run(['rev-parse', '--verify', 'origin/main']).ok) return; // remote has no main
88
+ this.run(['reset', '--hard', 'origin/main']);
89
+ this.run(['branch', '--set-upstream-to=origin/main', 'main']);
72
90
  }
73
91
 
74
92
  ensureRemote(remote: string): void {