@thedevlabs-io/labd 0.6.1 → 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);
@@ -3611,6 +3746,78 @@ var Viewport = class {
3611
3746
  }
3612
3747
  };
3613
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
+
3614
3821
  // packages/tui/src/face.ts
3615
3822
  var FACE_ART = [
3616
3823
  " \u250F\u2501 \u2501\u2513 ",
@@ -4130,7 +4337,7 @@ var BUILTIN_COMMANDS = [
4130
4337
  { name: "session", what: "where this one is stored" },
4131
4338
  { name: "rename", args: "<name>", what: "name this session, so /sessions and --resume show it" },
4132
4339
  { name: "new", what: "start a fresh session; this one stays saved" },
4133
- { 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" },
4134
4341
  { name: "trust", what: "let this project load its own extensions" },
4135
4342
  { name: "logout", what: "sign this machine out and leave" },
4136
4343
  { name: "reload", what: "reload extensions and the file list" },
@@ -4440,12 +4647,23 @@ async function handleCommand(input, ctx) {
4440
4647
  return { effort: args };
4441
4648
  }
4442
4649
  case "usage": {
4443
- const credits = await runtime.client.credits();
4650
+ const [credits, activity] = await Promise.all([
4651
+ runtime.client.credits(),
4652
+ runtime.client.activity(utcOffsetMinutes())
4653
+ ]);
4444
4654
  if (!credits) {
4445
4655
  print(` ${paint.muted("Couldn't check that just now.")}`);
4446
4656
  } else {
4447
4657
  print(` ${paint.bold(`${credits.percentLeft}%`)} ${paint.muted("of this month left")}`);
4448
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
+ }
4449
4667
  return {};
4450
4668
  }
4451
4669
  case "tree": {
@@ -4474,9 +4692,12 @@ async function handleCommand(input, ctx) {
4474
4692
  print(` ${paint.yellow("!")} ${paint.muted(`no point here starts with "${args}"`)}`);
4475
4693
  return {};
4476
4694
  }
4695
+ const moved = match.id !== runtime.session.headId;
4477
4696
  runtime.session.moveTo(match.id);
4478
- print(` ${paint.muted(`continuing from ${match.id.slice(0, 8)}. The other branch is still in the file.`)}`);
4479
- 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 };
4480
4701
  }
4481
4702
  case "sessions": {
4482
4703
  const bucket = dirname3(runtime.session.file);
@@ -4792,6 +5013,7 @@ async function runInteractive(runtime) {
4792
5013
  mode: state.mode,
4793
5014
  effort: state.effort,
4794
5015
  asked: answered > 0,
5016
+ width: process.stdout.columns ?? 80,
4795
5017
  print: (text) => renderer.print(text)
4796
5018
  });
4797
5019
  if (outcome.farewell) process.stderr.write(`${outcome.farewell}
@@ -4799,6 +5021,7 @@ async function runInteractive(runtime) {
4799
5021
  if (outcome.exit) break;
4800
5022
  if (outcome.mode) state.mode = outcome.mode;
4801
5023
  if (outcome.effort !== void 0) state.effort = outcome.effort;
5024
+ if (outcome.moved) state.context = null;
4802
5025
  continue;
4803
5026
  }
4804
5027
  const tags = taggedPaths(input);
@@ -4913,6 +5136,7 @@ async function runInteractive(runtime) {
4913
5136
  if (state.context && state.context.percentUsed >= COMPACT_AT) {
4914
5137
  startSpinner("summarising, to make room");
4915
5138
  const ok = await compact({ session: runtime.session, client: runtime.client, root: runtime.root });
5139
+ if (ok) state.context = null;
4916
5140
  stopSpinner();
4917
5141
  renderer.print(paint.dim(ok ? " summarised the conversation so far." : " (could not summarise; carrying on)"));
4918
5142
  }
@@ -5511,6 +5735,7 @@ async function runScreen(runtime, opts) {
5511
5735
  mode: state.mode,
5512
5736
  effort: state.effort,
5513
5737
  asked: state.asked,
5738
+ width: layout({ rows: screen.rows, columns: screen.columns, editorLines: editor.lineCount, sidebar: state.sidebar }).transcript.width,
5514
5739
  print: (text) => note(text)
5515
5740
  });
5516
5741
  if (outcome.farewell) state.farewell = outcome.farewell;
@@ -5520,6 +5745,7 @@ async function runScreen(runtime, opts) {
5520
5745
  state.heldMode = null;
5521
5746
  }
5522
5747
  if (outcome.effort !== void 0) state.effort = outcome.effort;
5748
+ if (outcome.moved) state.context = null;
5523
5749
  schedule();
5524
5750
  }
5525
5751
  function helpLines() {
@@ -5592,6 +5818,7 @@ async function runScreen(runtime, opts) {
5592
5818
  if (state.asked) await closeConversation(runtime);
5593
5819
  runtime.session = Session.open(file);
5594
5820
  state.asked = false;
5821
+ state.context = null;
5595
5822
  transcript.clear();
5596
5823
  state.toolBlocks.clear();
5597
5824
  state.answer = null;
@@ -5602,6 +5829,7 @@ async function runScreen(runtime, opts) {
5602
5829
  if (state.asked) await closeConversation(runtime);
5603
5830
  runtime.session = Session.create(bucketFor(runtime.stateDir, runtime.root));
5604
5831
  state.asked = false;
5832
+ state.context = null;
5605
5833
  transcript.clear();
5606
5834
  state.toolBlocks.clear();
5607
5835
  state.answer = null;
@@ -5728,8 +5956,10 @@ async function runScreen(runtime, opts) {
5728
5956
  if (!abort.signal.aborted && state.context && state.context.percentUsed >= COMPACT_AT) {
5729
5957
  setBusy("summarising, to make room");
5730
5958
  const ok = await compact({ session: runtime.session, client: runtime.client, root: runtime.root });
5731
- if (ok) transcript.add({ kind: "compaction" });
5732
- 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)"));
5733
5963
  }
5734
5964
  } catch (err) {
5735
5965
  transcript.add({ kind: "error", text: err instanceof Error ? err.message : String(err) });
@@ -6153,7 +6383,7 @@ async function runRpc(runtime) {
6153
6383
  }
6154
6384
 
6155
6385
  // packages/cli/src/index.ts
6156
- var VERSION = true ? "0.6.1" : "0.0.0-dev";
6386
+ var VERSION = true ? "0.7.0" : "0.0.0-dev";
6157
6387
  async function main(argv) {
6158
6388
  const args = parse(argv);
6159
6389
  if (args.color !== void 0) setColor(args.color);