heyio 0.5.0 → 0.8.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.
@@ -39,6 +39,28 @@ export function listRecentTasks(limit = 50) {
39
39
  .prepare("SELECT * FROM agent_tasks ORDER BY datetime(started_at) DESC, task_id DESC LIMIT ?")
40
40
  .all(limit);
41
41
  }
42
+ /**
43
+ * Per-agent task count for the most recent `limit` tasks belonging to a
44
+ * squad. Matches tasks routed to the squad itself (`agent_slug = squadSlug`)
45
+ * AND tasks routed to a named agent on the squad (`agent_slug LIKE 'squadSlug:%'`).
46
+ * Used by squad_status to surface fan-out imbalance.
47
+ */
48
+ export function getSquadWorkDistribution(squadSlug, limit = 20) {
49
+ const rows = getDb()
50
+ .prepare(`SELECT agent_slug FROM agent_tasks
51
+ WHERE agent_slug = ? OR agent_slug LIKE ?
52
+ ORDER BY datetime(started_at) DESC, task_id DESC
53
+ LIMIT ?`)
54
+ .all(squadSlug, `${squadSlug}:%`, limit);
55
+ const counts = new Map();
56
+ for (const row of rows) {
57
+ counts.set(row.agent_slug, (counts.get(row.agent_slug) ?? 0) + 1);
58
+ }
59
+ const perAgent = Array.from(counts.entries())
60
+ .map(([agent_slug, count]) => ({ agent_slug, count }))
61
+ .sort((a, b) => b.count - a.count);
62
+ return { total: rows.length, perAgent };
63
+ }
42
64
  export function createReview(taskId, squadSlug, reviewerCharacter, approved, comments) {
43
65
  const db = getDb();
44
66
  const info = db
@@ -53,4 +75,104 @@ export function getTaskReviews(taskId) {
53
75
  .prepare("SELECT * FROM squad_task_reviews WHERE task_id = ? ORDER BY created_at ASC, id ASC")
54
76
  .all(taskId);
55
77
  }
78
+ /**
79
+ * Per-character delegation stats for a squad.
80
+ *
81
+ * Returns one row PER CHARACTER NAME passed in `characterNames`, plus an
82
+ * extra row with character_name="" for any tasks routed to the bare squad
83
+ * slug (legacy lead tasks). Always returns a row for every requested
84
+ * character, even if they have never been delegated to (task_count: 0,
85
+ * last_delegated_at: null).
86
+ *
87
+ * Reads from the agent_stats view. Filters with `agent_slug = ?`
88
+ * (for the bare slug) and `agent_slug = ?` for each `<slug>:<char>`.
89
+ */
90
+ export function getAgentTaskStats(squadSlug, characterNames) {
91
+ // Build the full set of agent_slug values we care about
92
+ const bareSlug = squadSlug;
93
+ const namedSlugs = characterNames.map((c) => `${squadSlug}:${c}`);
94
+ const allSlugs = [bareSlug, ...namedSlugs];
95
+ const placeholders = allSlugs.map(() => "?").join(", ");
96
+ const rows = getDb()
97
+ .prepare(`SELECT agent_slug, task_count, last_delegated_at FROM agent_stats WHERE agent_slug IN (${placeholders})`)
98
+ .all(...allSlugs);
99
+ const bySlug = new Map();
100
+ for (const row of rows)
101
+ bySlug.set(row.agent_slug, row);
102
+ const results = [];
103
+ // Bare slug row (legacy lead tasks routed without a named agent)
104
+ const bareRow = bySlug.get(bareSlug);
105
+ results.push({
106
+ character_name: "",
107
+ agent_slug: bareSlug,
108
+ task_count: bareRow?.task_count ?? 0,
109
+ last_delegated_at: bareRow?.last_delegated_at ?? null,
110
+ });
111
+ // One row per requested character
112
+ for (const char of characterNames) {
113
+ const slug = `${squadSlug}:${char}`;
114
+ const row = bySlug.get(slug);
115
+ results.push({
116
+ character_name: char,
117
+ agent_slug: slug,
118
+ task_count: row?.task_count ?? 0,
119
+ last_delegated_at: row?.last_delegated_at ?? null,
120
+ });
121
+ }
122
+ return results;
123
+ }
124
+ /**
125
+ * Pick the stalest specialist in a squad. "Stalest" = the character who
126
+ * has been delegated to least recently (oldest last_delegated_at), with
127
+ * never-delegated agents considered staler than any delegated agent.
128
+ *
129
+ * Excludes character names listed in `excludeCharacters` (use this to
130
+ * skip the lead). Returns null if the squad has no eligible agents OR if
131
+ * all eligible agents have been delegated to within `freshIfWithinHours`
132
+ * (default 48). The threshold is meant to suppress the hint when the
133
+ * squad is already distributing well.
134
+ *
135
+ * On tie (e.g. two agents have never been delegated), returns the one
136
+ * that sorts first by character_name (deterministic).
137
+ */
138
+ export function getStalestSpecialist(squadSlug, characterNames, options) {
139
+ const exclude = new Set(options?.excludeCharacters ?? []);
140
+ const freshThresholdHours = options?.freshIfWithinHours ?? 48;
141
+ const stats = getAgentTaskStats(squadSlug, characterNames);
142
+ // Filter: named agents only (skip the bare-slug "" row), skip excluded
143
+ const eligible = stats.filter((s) => s.character_name !== "" && !exclude.has(s.character_name));
144
+ if (eligible.length === 0)
145
+ return null;
146
+ const now = Date.now();
147
+ // Sort: never-delegated (null) first, then ascending by last_delegated_at
148
+ eligible.sort((a, b) => {
149
+ if (a.last_delegated_at === null && b.last_delegated_at === null) {
150
+ return a.character_name.localeCompare(b.character_name);
151
+ }
152
+ if (a.last_delegated_at === null)
153
+ return -1;
154
+ if (b.last_delegated_at === null)
155
+ return 1;
156
+ const tA = new Date(a.last_delegated_at + "Z").getTime();
157
+ const tB = new Date(b.last_delegated_at + "Z").getTime();
158
+ if (tA !== tB)
159
+ return tA - tB;
160
+ return a.character_name.localeCompare(b.character_name);
161
+ });
162
+ const stalest = eligible[0];
163
+ let staleHours = null;
164
+ if (stalest.last_delegated_at !== null) {
165
+ const delegatedAt = new Date(stalest.last_delegated_at + "Z").getTime();
166
+ staleHours = Math.round((now - delegatedAt) / 3_600_000);
167
+ // Squad is distributing well — suppress the hint
168
+ if (staleHours < freshThresholdHours)
169
+ return null;
170
+ }
171
+ // null last_delegated_at means never-delegated: always considered stale
172
+ return {
173
+ character_name: stalest.character_name,
174
+ last_delegated_at: stalest.last_delegated_at,
175
+ staleHours,
176
+ };
177
+ }
56
178
  //# sourceMappingURL=tasks.js.map
@@ -141,4 +141,18 @@ export async function sendProactiveMessage(text) {
141
141
  }
142
142
  }
143
143
  }
144
+ /**
145
+ * Send a background schedule result as a Telegram notification.
146
+ * Delegates to sendProactiveMessage for chunking, bot-not-configured guards,
147
+ * and authorized-user checks — no extra logic needed here.
148
+ *
149
+ * Format (plain text, no parse_mode):
150
+ * 🔔 Background update — <title>
151
+ *
152
+ * <text>
153
+ */
154
+ export async function sendBackgroundNotification(opts) {
155
+ const formatted = `\ud83d\udd14 Background update \u2014 ${opts.title}\n\n${opts.text}`;
156
+ await sendProactiveMessage(formatted);
157
+ }
144
158
  //# sourceMappingURL=bot.js.map
package/dist/tui/index.js CHANGED
@@ -17,6 +17,76 @@ Type a message to chat. Commands:
17
17
  /quit — exit
18
18
  `;
19
19
  let verbose = false;
20
+ // Held so printBackgroundNotification can redraw the prompt around notifications.
21
+ // Assigned inside startTui(); undefined when running headless (no TUI).
22
+ let activeInterface;
23
+ const NOTIFICATION_MAX_LINES = 6;
24
+ const NOTIFICATION_WRAP_WIDTH = 78;
25
+ /** Hard-wrap a single line to at most `width` visible chars, returning segments. */
26
+ function wrapLine(line, width) {
27
+ const segments = [];
28
+ while (line.length > width) {
29
+ segments.push(line.slice(0, width));
30
+ line = line.slice(width);
31
+ }
32
+ segments.push(line);
33
+ return segments;
34
+ }
35
+ /**
36
+ * Print a background notification in a bordered block above the prompt,
37
+ * preserving any in-progress readline input the user has typed.
38
+ *
39
+ * Format:
40
+ * ╭─🔔 Background update: <title>
41
+ * │ <line1>
42
+ * │ […N more lines — see /notifications] (if truncated)
43
+ * ╰─
44
+ *
45
+ * Safe to call at any time, even before startTui(). Never throws.
46
+ */
47
+ export function printBackgroundNotification(opts) {
48
+ try {
49
+ // Build display lines from text, hard-wrapping long lines.
50
+ const rawLines = opts.text.split(/\r?\n/);
51
+ const displayLines = [];
52
+ for (const raw of rawLines) {
53
+ for (const seg of wrapLine(raw, NOTIFICATION_WRAP_WIDTH)) {
54
+ displayLines.push(seg);
55
+ }
56
+ }
57
+ const truncated = displayLines.length > NOTIFICATION_MAX_LINES;
58
+ const visible = truncated ? displayLines.slice(0, NOTIFICATION_MAX_LINES) : displayLines;
59
+ const extra = displayLines.length - NOTIFICATION_MAX_LINES;
60
+ const top = "\u256d\u2500\ud83d\udd14 Background update: " + opts.title + "\n";
61
+ const body = visible.map((l) => "\u2502 " + l).join("\n");
62
+ const overflow = truncated
63
+ ? "\n\u2502 [\u2026" + extra + " more line" + (extra === 1 ? "" : "s") + " \u2014 see /notifications]"
64
+ : "";
65
+ const bottom = "\n\u2570\u2500\n";
66
+ const block = top + body + overflow + bottom;
67
+ if (!activeInterface) {
68
+ // Headless daemon — no readline interface live; plain log is fine.
69
+ process.stdout.write(block);
70
+ return;
71
+ }
72
+ // Capture whatever the user has typed so far.
73
+ // readline stores the pending input in `rl.line` (stable internal property).
74
+ const currentLine = activeInterface.line ?? "";
75
+ // Clear the current prompt+input line, print the notification, then
76
+ // redraw the prompt with the user's buffer.
77
+ process.stdout.write("\r\x1b[K");
78
+ process.stdout.write(block);
79
+ if (currentLine === "") {
80
+ activeInterface.prompt(true);
81
+ }
82
+ else {
83
+ process.stdout.write("io> " + currentLine);
84
+ }
85
+ }
86
+ catch (err) {
87
+ console.error("[io] printBackgroundNotification failed:", err instanceof Error ? err.message : String(err));
88
+ }
89
+ }
20
90
  function renderActivity(taskIdArg) {
21
91
  const recent = listRecentTasks(20);
22
92
  let task = undefined;
@@ -62,6 +132,9 @@ export async function startTui() {
62
132
  input: process.stdin,
63
133
  output: process.stdout,
64
134
  });
135
+ // Keep a module-level reference so printBackgroundNotification can redraw
136
+ // the prompt without disturbing the user's in-progress input.
137
+ activeInterface = rl;
65
138
  console.log(WELCOME_BANNER);
66
139
  rl.setPrompt("io> ");
67
140
  rl.prompt();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyio",
3
- "version": "0.5.0",
3
+ "version": "0.8.0",
4
4
  "description": "IO — a personal AI assistant built on the GitHub Copilot SDK",
5
5
  "bin": {
6
6
  "io": "dist/index.js"
@@ -18,7 +18,8 @@
18
18
  "daemon": "tsx src/daemon.ts",
19
19
  "tui": "tsx src/tui/index.ts",
20
20
  "dev": "tsx --watch src/daemon.ts",
21
- "prepublishOnly": "npm run build:all"
21
+ "prepublishOnly": "npm run build:all",
22
+ "test": "node --import tsx --test 'src/**/*.test.ts'"
22
23
  },
23
24
  "engines": {
24
25
  "node": ">=22"