@thedevlabs-io/labd 0.6.0 → 0.7.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/README.md CHANGED
@@ -129,7 +129,7 @@ more columns.
129
129
  | `/sessions`, `/session` | earlier sessions here (a dialog; `enter` resumes); where this one is stored |
130
130
  | `/new` | start a fresh session; the current one stays saved |
131
131
  | `/sidebar`, `/clear` | show or hide the side panel; clear the screen |
132
- | `/usage` | what's left of this month's allowance |
132
+ | `/usage` | what's left of this month's allowance, and a heatmap of your activity |
133
133
  | `/trust` | let this project load its own extensions |
134
134
  | `/reload` | reload extensions and the file list |
135
135
  | `/quit` | leave - the session is saved, and `labd --resume <id>` continues it |
package/dist/labd.js CHANGED
@@ -3,6 +3,117 @@
3
3
  // packages/cli/src/index.ts
4
4
  import { resolve } from "node:path";
5
5
 
6
+ // vendor/labd/packages/protocol/src/activity.ts
7
+ var ACTIVITY_WEEKS = 53;
8
+ function utcOffsetMinutes(at2 = /* @__PURE__ */ new Date()) {
9
+ return -at2.getTimezoneOffset();
10
+ }
11
+ var DAY_MS = 864e5;
12
+ function localDate(at2 = /* @__PURE__ */ new Date()) {
13
+ const pad4 = (n) => String(n).padStart(2, "0");
14
+ return `${at2.getFullYear()}-${pad4(at2.getMonth() + 1)}-${pad4(at2.getDate())}`;
15
+ }
16
+ function dayNumber(date) {
17
+ return Math.floor(Date.parse(`${date}T00:00:00Z`) / DAY_MS);
18
+ }
19
+ function dateOf(day) {
20
+ return new Date(day * DAY_MS).toISOString().slice(0, 10);
21
+ }
22
+ function summariseActivity(activity, weeks, today = localDate()) {
23
+ const span = Math.max(1, Math.min(ACTIVITY_WEEKS, Math.floor(weeks)));
24
+ const last = dayNumber(today);
25
+ const monday = last - (last + 3) % 7;
26
+ const first = monday - (span - 1) * 7;
27
+ const earliest = monday - (ACTIVITY_WEEKS - 1) * 7;
28
+ const perDay = /* @__PURE__ */ new Map();
29
+ const perSurface = /* @__PURE__ */ new Map();
30
+ const totals = { turns: 0, input: 0, output: 0, activeDays: 0 };
31
+ for (const bucket of activity.days) {
32
+ const day = dayNumber(bucket.day);
33
+ if (!(day >= earliest && day <= last)) continue;
34
+ const tokens = bucket.input + bucket.output;
35
+ const d = perDay.get(day) ?? { turns: 0, tokens: 0 };
36
+ d.turns += bucket.turns;
37
+ d.tokens += tokens;
38
+ perDay.set(day, d);
39
+ if (day < first) continue;
40
+ const s = perSurface.get(bucket.surface) ?? { turns: 0, tokens: 0 };
41
+ s.turns += bucket.turns;
42
+ s.tokens += tokens;
43
+ perSurface.set(bucket.surface, s);
44
+ totals.turns += bucket.turns;
45
+ totals.input += bucket.input;
46
+ totals.output += bucket.output;
47
+ }
48
+ let run = 0;
49
+ let longest = 0;
50
+ for (let day = earliest; day <= last; day++) {
51
+ if ((perDay.get(day)?.turns ?? 0) > 0) {
52
+ run += 1;
53
+ longest = Math.max(longest, run);
54
+ } else if (day !== last) {
55
+ run = 0;
56
+ }
57
+ }
58
+ const shown = [...perDay.entries()].filter(([day]) => day >= first).map(([, d]) => d.tokens);
59
+ const ranked = [...new Set(shown.filter((t) => t > 0))].sort((a, b) => a - b);
60
+ const days = [];
61
+ let busiest = null;
62
+ for (let day = first; day <= last; day++) {
63
+ const d = perDay.get(day) ?? { turns: 0, tokens: 0 };
64
+ const entry = { date: dateOf(day), turns: d.turns, tokens: d.tokens, level: levelFor(d.tokens, ranked) };
65
+ days.push(entry);
66
+ if (d.turns > 0) totals.activeDays += 1;
67
+ if (d.tokens > 0 && (!busiest || d.tokens > busiest.tokens)) busiest = entry;
68
+ }
69
+ const hours = activity.hours.length === 24 ? activity.hours : [];
70
+ const peak = Math.max(0, ...hours);
71
+ return {
72
+ days,
73
+ totals,
74
+ streak: { current: run, longest },
75
+ peakHour: peak > 0 ? hours.indexOf(peak) : null,
76
+ busiest,
77
+ surfaces: [...perSurface.entries()].map(([surface, s]) => ({ surface, ...s })).sort((a, b) => b.tokens - a.tokens || a.surface.localeCompare(b.surface))
78
+ };
79
+ }
80
+ function levelFor(tokens, ranked) {
81
+ if (!(tokens > 0)) return 0;
82
+ if (ranked.length === 1) return 4;
83
+ return 1 + Math.round(3 * ranked.indexOf(tokens) / (ranked.length - 1));
84
+ }
85
+ function formatTokens(n) {
86
+ const units = ["", "k", "M", "B"];
87
+ let value = n;
88
+ let unit = 0;
89
+ while (value >= 999.5 && unit < units.length - 1) {
90
+ value /= 1e3;
91
+ unit += 1;
92
+ }
93
+ const shown = unit === 0 || value >= 10 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, "");
94
+ return `${shown}${units[unit]}`;
95
+ }
96
+ function surfaceLabel(surface) {
97
+ switch (surface) {
98
+ case "web":
99
+ return "portal";
100
+ case "vscode":
101
+ return "editor";
102
+ case "cli":
103
+ return "terminal";
104
+ case "memory":
105
+ return "memory (background)";
106
+ case "api":
107
+ return "API";
108
+ default:
109
+ return surface;
110
+ }
111
+ }
112
+ function formatHour(hour) {
113
+ const h = hour % 12 === 0 ? 12 : hour % 12;
114
+ return `${h}${hour < 12 ? "am" : "pm"}`;
115
+ }
116
+
6
117
  // vendor/labd/packages/protocol/src/tags.ts
7
118
  var TAG = /(^|[\s([])@(?:"([^"\n]+)"|([^\s"]+))/g;
8
119
  var TRAILING = /[.,;:!?)\]}'"]+$/;
@@ -1556,8 +1667,6 @@ async function runTurn(question, opts) {
1556
1667
  needTools = true;
1557
1668
  return "go";
1558
1669
  case "done":
1559
- result.context = event.context;
1560
- result.credits = event.credits;
1561
1670
  ui.done?.({ credits: event.credits, context: event.context });
1562
1671
  return "go";
1563
1672
  case "error":
@@ -2001,6 +2110,18 @@ var AgentClient = class {
2001
2110
  return null;
2002
2111
  }
2003
2112
  }
2113
+ /** The learner's usage by local hour, for the heatmap under `/usage`. */
2114
+ async activity(offsetMinutes) {
2115
+ try {
2116
+ const res = await this.fetch(`${this.opts.baseUrl}/v1/usage/activity?offset=${offsetMinutes}`, { headers: this.headers() });
2117
+ if (!res.ok) return null;
2118
+ const body2 = await res.json();
2119
+ if (!Array.isArray(body2.days) || !Array.isArray(body2.hours)) return null;
2120
+ return { days: body2.days.filter(isBucket), hours: body2.hours.map((n) => isCount(n) ? n : 0) };
2121
+ } catch {
2122
+ return null;
2123
+ }
2124
+ }
2004
2125
  /**
2005
2126
  * Tell the server this conversation is over and can be distilled into memory.
2006
2127
  *
@@ -2078,6 +2199,13 @@ var AgentClient = class {
2078
2199
  }
2079
2200
  }
2080
2201
  };
2202
+ function isBucket(value) {
2203
+ const b = value;
2204
+ return typeof b?.day === "string" && typeof b.surface === "string" && [b.turns, b.input, b.output].every(isCount);
2205
+ }
2206
+ function isCount(n) {
2207
+ return typeof n === "number" && Number.isFinite(n);
2208
+ }
2081
2209
 
2082
2210
  // packages/tui/src/theme.ts
2083
2211
  var ESC = "\x1B";
@@ -2103,6 +2231,14 @@ var paint = {
2103
2231
  * Closes the foreground only, so it can sit inside a `band` without ending it.
2104
2232
  */
2105
2233
  accent: wrap("38;5;214", "39"),
2234
+ /**
2235
+ * The heatmap's lower steps, walking the same orange down the cube towards
2236
+ * brown, with `accent` as the top one. The heatmap changes glyph with each
2237
+ * step too, so a light terminal where brown reads heavier is still legible.
2238
+ */
2239
+ heat1: wrap("38;5;94", "39"),
2240
+ heat2: wrap("38;5;130", "39"),
2241
+ heat3: wrap("38;5;172", "39"),
2106
2242
  bold: wrap("1", "22"),
2107
2243
  dim: wrap("2", "22"),
2108
2244
  muted: wrap("90"),
@@ -2383,8 +2519,7 @@ function statusLine(info2) {
2383
2519
  if (info2.effort) bits.push(paint.muted(`think ${info2.effort}`));
2384
2520
  const mode = info2.mode === "agent" ? paint.red(paint.bold("agent")) : info2.mode === "auto" ? paint.accent("auto") : paint.muted("ask");
2385
2521
  bits.push(mode);
2386
- const left = info2.busy ? `${paint.accent(FACE)} ${paint.dim(info2.busy)}` : paint.dim(FACE);
2387
- return `${left} ${paint.dim("|")} ${bits.join(paint.dim(" | "))}`;
2522
+ return `${paint.dim(FACE)} ${paint.dim("|")} ${bits.join(paint.dim(" | "))}`;
2388
2523
  }
2389
2524
  function spinnerFrame(tick) {
2390
2525
  const dots = ".".repeat(tick % 3 + 1).padEnd(3);
@@ -3391,7 +3526,6 @@ function wrap2(text, width, first, rest) {
3391
3526
  }
3392
3527
 
3393
3528
  // packages/tui/src/transcript.ts
3394
- var MAX_PROSE_WIDTH = 100;
3395
3529
  var LIVE_LINES = 8;
3396
3530
  var Transcript = class {
3397
3531
  entries = [];
@@ -3493,7 +3627,7 @@ function splitStreaming(text) {
3493
3627
  return { complete: complete2.join("\n"), tail };
3494
3628
  }
3495
3629
  function proseWidth(width) {
3496
- return Math.max(20, Math.min(width - 2, MAX_PROSE_WIDTH));
3630
+ return Math.max(20, width - 2);
3497
3631
  }
3498
3632
  function renderBlock(block, width) {
3499
3633
  const inner = proseWidth(width);
@@ -3502,7 +3636,7 @@ function renderBlock(block, width) {
3502
3636
  return block.text.split("\n").flatMap(
3503
3637
  (line, i) => wrapPlain(line, inner - 2).map((piece, j) => {
3504
3638
  const text = i === 0 && j === 0 ? `${paint.accent(">")} ${piece}` : ` ${piece}`;
3505
- return band(text, visibleWidth(text), inner);
3639
+ return band(text, visibleWidth(text), width);
3506
3640
  })
3507
3641
  );
3508
3642
  case "assistant":
@@ -3612,6 +3746,78 @@ var Viewport = class {
3612
3746
  }
3613
3747
  };
3614
3748
 
3749
+ // packages/tui/src/heatmap.ts
3750
+ var INDENT = " ";
3751
+ var DAY_LABEL = ["Mon", " ", "Wed", " ", "Fri", " ", "Sun"];
3752
+ var GUTTER = INDENT.length + 4;
3753
+ var MIN_WEEKS = 4;
3754
+ var GLYPH = { 0: "\xB7", 1: "\u2591", 2: "\u2592", 3: "\u2593", 4: "\u2588" };
3755
+ var SHADE = {
3756
+ 0: paint.dim,
3757
+ 1: paint.heat1,
3758
+ 2: paint.heat2,
3759
+ 3: paint.heat3,
3760
+ 4: paint.accent
3761
+ };
3762
+ function heatmapWeeks(width) {
3763
+ return Math.max(MIN_WEEKS, Math.min(ACTIVITY_WEEKS, Math.floor((width - GUTTER) / 2)));
3764
+ }
3765
+ function heatmapLines(summary, width) {
3766
+ const { days, totals, streak } = summary;
3767
+ const columns = Math.ceil(days.length / 7);
3768
+ const lines = [`${INDENT}${paint.muted(`last ${columns} weeks`)}`, `${" ".repeat(GUTTER)}${monthRow(days, columns)}`];
3769
+ for (let row2 = 0; row2 < 7; row2++) {
3770
+ let cells = "";
3771
+ for (let column = 0; column < columns; column++) {
3772
+ const day = days[column * 7 + row2];
3773
+ cells += day ? `${SHADE[day.level](GLYPH[day.level])} ` : " ";
3774
+ }
3775
+ lines.push(`${INDENT}${paint.dim(DAY_LABEL[row2])} ${cells.trimEnd()}`);
3776
+ }
3777
+ const legend = [0, 1, 2, 3, 4].map((l) => SHADE[l](GLYPH[l])).join(" ");
3778
+ lines.push(`${" ".repeat(GUTTER)}${paint.dim("less")} ${legend} ${paint.dim("more")}`, "");
3779
+ const row = (label2, value) => `${INDENT}${paint.muted(label2.padEnd(10))}${value}`;
3780
+ lines.push(
3781
+ row("tokens", `${paint.bold(formatTokens(totals.input + totals.output))} ${paint.dim(`${formatTokens(totals.input)} in \xB7 ${formatTokens(totals.output)} out`)}`),
3782
+ row("requests", `${paint.bold(String(totals.turns))} ${paint.dim(`on ${plural(totals.activeDays, "day")}`)}`),
3783
+ row("streak", `${paint.bold(plural(streak.current, "day"))} ${paint.dim(`longest ${plural(streak.longest, "day")}`)}`)
3784
+ );
3785
+ if (summary.busiest) {
3786
+ lines.push(row("busiest", `${paint.bold(dayName(summary.busiest.date))} ${paint.dim(`${formatTokens(summary.busiest.tokens)} tokens`)}`));
3787
+ }
3788
+ if (summary.peakHour !== null) lines.push(row("peak hour", paint.bold(formatHour(summary.peakHour))));
3789
+ const where = summary.surfaces.map((s) => `${surfaceLabel(s.surface)} ${paint.bold(formatTokens(s.tokens))}`);
3790
+ const joined = row("where", where.join(paint.dim(" \xB7 ")));
3791
+ if (!where.length) return lines;
3792
+ if (visibleWidth(joined) <= width) lines.push(joined);
3793
+ else where.forEach((w, i) => lines.push(row(i === 0 ? "where" : "", w)));
3794
+ return lines;
3795
+ }
3796
+ function monthRow(days, columns) {
3797
+ const starts = [];
3798
+ for (let column = 0; column < columns; column++) {
3799
+ const week = days.slice(column * 7, column * 7 + 7);
3800
+ const first = column === 0 ? week[0] : week.find((d) => d.date.endsWith("-01"));
3801
+ if (first && column + 1 < columns) starts.push({ column, date: first.date });
3802
+ }
3803
+ if (starts.length > 1 && starts[1].column < 2) starts.shift();
3804
+ let out = "";
3805
+ for (const { column, date } of starts) {
3806
+ out = out.padEnd(column * 2) + localDay(date).toLocaleDateString("en-GB", { month: "short" }).slice(0, 3);
3807
+ }
3808
+ return paint.dim(out);
3809
+ }
3810
+ function localDay(date) {
3811
+ const [y, m, d] = date.split("-").map(Number);
3812
+ return new Date(y, m - 1, d);
3813
+ }
3814
+ function dayName(date) {
3815
+ return localDay(date).toLocaleDateString("en-GB", { day: "numeric", month: "short" });
3816
+ }
3817
+ function plural(n, word) {
3818
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
3819
+ }
3820
+
3615
3821
  // packages/tui/src/face.ts
3616
3822
  var FACE_ART = [
3617
3823
  " \u250F\u2501 \u2501\u2513 ",
@@ -4131,7 +4337,7 @@ var BUILTIN_COMMANDS = [
4131
4337
  { name: "session", what: "where this one is stored" },
4132
4338
  { name: "rename", args: "<name>", what: "name this session, so /sessions and --resume show it" },
4133
4339
  { name: "new", what: "start a fresh session; this one stays saved" },
4134
- { name: "usage", what: "what's left of this month's allowance" },
4340
+ { name: "usage", what: "what's left of this month's allowance, and your activity" },
4135
4341
  { name: "trust", what: "let this project load its own extensions" },
4136
4342
  { name: "logout", what: "sign this machine out and leave" },
4137
4343
  { name: "reload", what: "reload extensions and the file list" },
@@ -4441,12 +4647,23 @@ async function handleCommand(input, ctx) {
4441
4647
  return { effort: args };
4442
4648
  }
4443
4649
  case "usage": {
4444
- const credits = await runtime.client.credits();
4650
+ const [credits, activity] = await Promise.all([
4651
+ runtime.client.credits(),
4652
+ runtime.client.activity(utcOffsetMinutes())
4653
+ ]);
4445
4654
  if (!credits) {
4446
4655
  print(` ${paint.muted("Couldn't check that just now.")}`);
4447
4656
  } else {
4448
4657
  print(` ${paint.bold(`${credits.percentLeft}%`)} ${paint.muted("of this month left")}`);
4449
4658
  }
4659
+ if (activity && !activity.days.length) {
4660
+ print(` ${paint.muted("No activity yet. Ask labd something and it shows up here.")}`);
4661
+ } else if (activity) {
4662
+ print("");
4663
+ print(heatmapLines(summariseActivity(activity, heatmapWeeks(ctx.width)), ctx.width).join("\n"));
4664
+ } else if (credits) {
4665
+ print(` ${paint.muted("Couldn't read your activity just now.")}`);
4666
+ }
4450
4667
  return {};
4451
4668
  }
4452
4669
  case "tree": {
@@ -4475,9 +4692,12 @@ async function handleCommand(input, ctx) {
4475
4692
  print(` ${paint.yellow("!")} ${paint.muted(`no point here starts with "${args}"`)}`);
4476
4693
  return {};
4477
4694
  }
4695
+ const moved = match.id !== runtime.session.headId;
4478
4696
  runtime.session.moveTo(match.id);
4479
- print(` ${paint.muted(`continuing from ${match.id.slice(0, 8)}. The other branch is still in the file.`)}`);
4480
- return {};
4697
+ print(
4698
+ ` ${paint.muted(moved ? `continuing from ${match.id.slice(0, 8)}. The other branch is still in the file.` : `already at ${match.id.slice(0, 8)}.`)}`
4699
+ );
4700
+ return { moved };
4481
4701
  }
4482
4702
  case "sessions": {
4483
4703
  const bucket = dirname3(runtime.session.file);
@@ -4793,6 +5013,7 @@ async function runInteractive(runtime) {
4793
5013
  mode: state.mode,
4794
5014
  effort: state.effort,
4795
5015
  asked: answered > 0,
5016
+ width: process.stdout.columns ?? 80,
4796
5017
  print: (text) => renderer.print(text)
4797
5018
  });
4798
5019
  if (outcome.farewell) process.stderr.write(`${outcome.farewell}
@@ -4800,6 +5021,7 @@ async function runInteractive(runtime) {
4800
5021
  if (outcome.exit) break;
4801
5022
  if (outcome.mode) state.mode = outcome.mode;
4802
5023
  if (outcome.effort !== void 0) state.effort = outcome.effort;
5024
+ if (outcome.moved) state.context = null;
4803
5025
  continue;
4804
5026
  }
4805
5027
  const tags = taggedPaths(input);
@@ -4914,6 +5136,7 @@ async function runInteractive(runtime) {
4914
5136
  if (state.context && state.context.percentUsed >= COMPACT_AT) {
4915
5137
  startSpinner("summarising, to make room");
4916
5138
  const ok = await compact({ session: runtime.session, client: runtime.client, root: runtime.root });
5139
+ if (ok) state.context = null;
4917
5140
  stopSpinner();
4918
5141
  renderer.print(paint.dim(ok ? " summarised the conversation so far." : " (could not summarise; carrying on)"));
4919
5142
  }
@@ -5512,6 +5735,7 @@ async function runScreen(runtime, opts) {
5512
5735
  mode: state.mode,
5513
5736
  effort: state.effort,
5514
5737
  asked: state.asked,
5738
+ width: layout({ rows: screen.rows, columns: screen.columns, editorLines: editor.lineCount, sidebar: state.sidebar }).transcript.width,
5515
5739
  print: (text) => note(text)
5516
5740
  });
5517
5741
  if (outcome.farewell) state.farewell = outcome.farewell;
@@ -5521,6 +5745,7 @@ async function runScreen(runtime, opts) {
5521
5745
  state.heldMode = null;
5522
5746
  }
5523
5747
  if (outcome.effort !== void 0) state.effort = outcome.effort;
5748
+ if (outcome.moved) state.context = null;
5524
5749
  schedule();
5525
5750
  }
5526
5751
  function helpLines() {
@@ -5593,6 +5818,7 @@ async function runScreen(runtime, opts) {
5593
5818
  if (state.asked) await closeConversation(runtime);
5594
5819
  runtime.session = Session.open(file);
5595
5820
  state.asked = false;
5821
+ state.context = null;
5596
5822
  transcript.clear();
5597
5823
  state.toolBlocks.clear();
5598
5824
  state.answer = null;
@@ -5603,6 +5829,7 @@ async function runScreen(runtime, opts) {
5603
5829
  if (state.asked) await closeConversation(runtime);
5604
5830
  runtime.session = Session.create(bucketFor(runtime.stateDir, runtime.root));
5605
5831
  state.asked = false;
5832
+ state.context = null;
5606
5833
  transcript.clear();
5607
5834
  state.toolBlocks.clear();
5608
5835
  state.answer = null;
@@ -5729,8 +5956,10 @@ async function runScreen(runtime, opts) {
5729
5956
  if (!abort.signal.aborted && state.context && state.context.percentUsed >= COMPACT_AT) {
5730
5957
  setBusy("summarising, to make room");
5731
5958
  const ok = await compact({ session: runtime.session, client: runtime.client, root: runtime.root });
5732
- if (ok) transcript.add({ kind: "compaction" });
5733
- else note(paint.dim(" (could not summarise; carrying on)"));
5959
+ if (ok) {
5960
+ state.context = null;
5961
+ transcript.add({ kind: "compaction" });
5962
+ } else note(paint.dim(" (could not summarise; carrying on)"));
5734
5963
  }
5735
5964
  } catch (err) {
5736
5965
  transcript.add({ kind: "error", text: err instanceof Error ? err.message : String(err) });
@@ -6154,7 +6383,7 @@ async function runRpc(runtime) {
6154
6383
  }
6155
6384
 
6156
6385
  // packages/cli/src/index.ts
6157
- var VERSION = true ? "0.6.0" : "0.0.0-dev";
6386
+ var VERSION = true ? "0.7.0" : "0.0.0-dev";
6158
6387
  async function main(argv) {
6159
6388
  const args = parse(argv);
6160
6389
  if (args.color !== void 0) setColor(args.color);