huddle-cli 0.1.0 โ 0.1.3
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/CHANGELOG.md +69 -0
- package/QUICKSTART.md +135 -0
- package/README.md +26 -4
- package/dist/adapters/amp.js +1 -1
- package/dist/adapters/antigravity.js +2 -0
- package/dist/adapters/claude.js +19 -3
- package/dist/adapters/codex.js +9 -1
- package/dist/adapters/cursor.js +12 -5
- package/dist/adapters/custom.js +4 -2
- package/dist/adapters/droid.js +1 -0
- package/dist/adapters/goose.js +2 -0
- package/dist/adapters/index.js +44 -18
- package/dist/adapters/kiro.js +1 -1
- package/dist/adapters/opencode.js +1 -1
- package/dist/app/live.js +142 -0
- package/dist/app/page.js +701 -0
- package/dist/app/server.js +262 -0
- package/dist/args.js +18 -0
- package/dist/attach/connect.js +7 -4
- package/dist/attach/guard.js +21 -2
- package/dist/attach/live.js +5 -1
- package/dist/attach/plug.js +21 -3
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +7 -2
- package/dist/cli.js +134 -42
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +57 -11
- package/dist/orchestrator/ask.js +13 -10
- package/dist/orchestrator/brain.js +16 -4
- package/dist/orchestrator/budget.js +283 -0
- package/dist/orchestrator/busy.js +106 -0
- package/dist/orchestrator/council.js +434 -87
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +2 -2
- package/dist/orchestrator/launcher.js +31 -13
- package/dist/orchestrator/learn.js +101 -0
- package/dist/orchestrator/planner.js +54 -9
- package/dist/orchestrator/qa.js +71 -15
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +575 -50
- package/dist/orchestrator/takeover.js +155 -0
- package/dist/platform.js +377 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +79 -16
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +2 -2
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +75 -20
- package/dist/room/mail.js +225 -0
- package/dist/room/progress.js +40 -1
- package/dist/room/server.js +220 -39
- package/dist/room/tunnel.js +6 -5
- package/dist/room/user.js +54 -0
- package/dist/sandbox/sync.js +4 -4
- package/dist/sandbox/worktree.js +145 -14
- package/dist/ui/app.js +963 -159
- package/dist/ui/brief.js +494 -0
- package/dist/ui/catalog.js +62 -24
- package/dist/ui/dashboard.js +269 -55
- package/dist/ui/doctor.js +15 -11
- package/dist/ui/export.js +1194 -0
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/narrator.js +56 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +11 -5
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +716 -94
- package/dist/ui/talk.js +176 -0
- package/dist/ui/term.js +182 -16
- package/dist/ui/transcript.js +434 -0
- package/dist/ui/watch.js +149 -48
- package/package.json +6 -3
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// `huddle lessons`: what Huddle learned across runs on this machine, and how
|
|
2
|
+
// to take a lesson back.
|
|
3
|
+
//
|
|
4
|
+
// huddle lessons list them, most-seen first
|
|
5
|
+
// huddle lessons --forget <id> drop one
|
|
6
|
+
// huddle lessons --clear [--yes] drop all (asks first)
|
|
7
|
+
import readline from "node:readline/promises";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import { C } from "./term.js";
|
|
10
|
+
import { allLessons, clearLessons, forgetLesson, lessonsFile, lessonsOff, repoKey } from "../room/lessons.js";
|
|
11
|
+
const ago = (ts) => {
|
|
12
|
+
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
|
13
|
+
return s < 90 ? `${s}s ago` : s < 5400 ? `${Math.round(s / 60)}m ago` : s < 129600 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago`;
|
|
14
|
+
};
|
|
15
|
+
function scopeLabel(l, here) {
|
|
16
|
+
if (l.scope === "all")
|
|
17
|
+
return "every run";
|
|
18
|
+
if (l.scope.startsWith("agent:"))
|
|
19
|
+
return l.scope.slice(6);
|
|
20
|
+
return here && l.scope === `repo:${here}` ? "this repo" : `repo ${l.scope.slice(5, 13)}`;
|
|
21
|
+
}
|
|
22
|
+
export async function runLessons(rest, cwd = process.cwd()) {
|
|
23
|
+
const file = lessonsFile().replace(os.homedir(), "~");
|
|
24
|
+
const fi = rest.indexOf("--forget");
|
|
25
|
+
if (fi >= 0) {
|
|
26
|
+
const id = rest[fi + 1];
|
|
27
|
+
if (!id || id.startsWith("--")) {
|
|
28
|
+
console.log(C.red("โ --forget needs a lesson id") + C.faint(" (the first column of `huddle lessons`)"));
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!forgetLesson(id)) {
|
|
33
|
+
console.log(C.red(`โ no lesson ${id}`) + C.faint(" โ `huddle lessons` lists them"));
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
return void console.log(`${C.green("โ")} forgot ${id}`);
|
|
38
|
+
}
|
|
39
|
+
if (rest.includes("--clear")) {
|
|
40
|
+
const n = allLessons().length;
|
|
41
|
+
if (!n)
|
|
42
|
+
return void console.log(C.faint("No lessons to clear."));
|
|
43
|
+
if (!rest.includes("--yes")) {
|
|
44
|
+
if (!process.stdin.isTTY) {
|
|
45
|
+
console.log(C.yellow(`! this deletes all ${n} lesson(s) in ${file}.`) + " Run it again with --yes to confirm.");
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
50
|
+
const a = (await rl.question(`Delete all ${n} lesson(s) in ${file}? ${C.faint("[y/N]")} `)).trim().toLowerCase();
|
|
51
|
+
rl.close();
|
|
52
|
+
if (!a.startsWith("y"))
|
|
53
|
+
return void console.log(C.faint("Kept them."));
|
|
54
|
+
}
|
|
55
|
+
clearLessons();
|
|
56
|
+
return void console.log(`${C.green("โ")} cleared ${n} lesson(s)`);
|
|
57
|
+
}
|
|
58
|
+
const lessons = allLessons().sort((a, b) => b.hits - a.hits || b.created - a.created);
|
|
59
|
+
if (lessonsOff())
|
|
60
|
+
console.log(C.yellow("! HUDDLE_NO_LESSONS is set: runs neither use nor learn lessons right now."));
|
|
61
|
+
if (!lessons.length) {
|
|
62
|
+
console.log(`No lessons yet ${C.faint(`(${file})`)}.`);
|
|
63
|
+
console.log(C.faint(" Huddle learns them at the end of each run โ a council turn that timed out, a file agents collided on, QA bugs โ and agents add them with huddle_remember(kind=\"lesson\")."));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
let here = null;
|
|
67
|
+
try {
|
|
68
|
+
here = repoKey(cwd);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* not in a repo */
|
|
72
|
+
}
|
|
73
|
+
console.log(`${C.violet("lessons")} ${C.faint(`ยท ${lessons.length} ยท ${file} ยท they go into agent and planner prompts`)}\n`);
|
|
74
|
+
for (const l of lessons) {
|
|
75
|
+
console.log(` ${C.bold(l.id)} ${l.text}`);
|
|
76
|
+
const used = l.last_used ? `used ${ago(l.last_used)}` : "not used yet";
|
|
77
|
+
console.log(C.faint(` ${scopeLabel(l, here)} ยท seen ${l.hits}ร ยท ${used} ยท from ${l.source || "?"}${l.evidence ? ` ยท ${l.evidence}` : ""}`));
|
|
78
|
+
}
|
|
79
|
+
console.log(C.faint(`\n huddle lessons --forget <id> ยท --clear ยท HUDDLE_NO_LESSONS=1 turns them off`));
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=lessons.js.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// One plain-English line on top of a live run: what's happening now, and
|
|
2
|
+
// whether you're needed.
|
|
3
|
+
//
|
|
4
|
+
// Found live (0.1.2 test): with four agents' notes scrolling by, the user
|
|
5
|
+
// couldn't tell what the team was doing, or whether it was waiting on them.
|
|
6
|
+
// The narrator answers both, every second, with words, not engineering:
|
|
7
|
+
// Now: the agents are each sketching how they'd build it ยท nothing needed from you
|
|
8
|
+
// Now: building โ 2 of 8 parts done ยท claude: argv parser ยท codex: conversion core 25%
|
|
9
|
+
// โ claude has a question for you โ answer below ยท picks option 1 in 41s
|
|
10
|
+
import { fmtDuration } from "../adapters/usage.js";
|
|
11
|
+
import { C, truncate } from "./term.js";
|
|
12
|
+
const COUNCIL = {
|
|
13
|
+
1: "the agents are each sketching how they'd build it",
|
|
14
|
+
2: "they're reading each other's ideas: what to borrow, what could go wrong",
|
|
15
|
+
3: "one agent is writing the plan from the best ideas",
|
|
16
|
+
4: "the others are double-checking the plan before anyone writes code",
|
|
17
|
+
};
|
|
18
|
+
/** "Build strict positional argv parser with UsageError" โ "strict positional argv parser". */
|
|
19
|
+
function shortTitle(t, n = 32) {
|
|
20
|
+
const s = t.replace(/^(build|create|write|add|implement|make|set up)\s+(the\s+|a\s+|an\s+)?/i, "").replace(/\s+(with|and|for|that)\s.*$/i, "");
|
|
21
|
+
return s.length > n ? s.slice(0, n - 1) + "โฆ" : s;
|
|
22
|
+
}
|
|
23
|
+
/** The narrator's words for the run as it stands (no colors), and whether it's waiting on you. */
|
|
24
|
+
export function narration(stats, now = Date.now()) {
|
|
25
|
+
const ask = stats.ask;
|
|
26
|
+
if (ask && ask.until > now)
|
|
27
|
+
return { needsYou: true, text: `${ask.from} has a question for you โ answer below ยท picks option 1 in ${fmtDuration(ask.until - now)}` };
|
|
28
|
+
const r = stats.run;
|
|
29
|
+
if (!r)
|
|
30
|
+
return { needsYou: false, text: "getting started" };
|
|
31
|
+
if (r.phase === "COUNCIL")
|
|
32
|
+
return { needsYou: false, text: COUNCIL[r.step ?? 1] ?? COUNCIL[1] };
|
|
33
|
+
if (r.phase === "PLAN")
|
|
34
|
+
return { needsYou: false, text: "splitting the work into parts" };
|
|
35
|
+
if (r.phase === "DONE")
|
|
36
|
+
return { needsYou: false, text: "done โ the summary is below" };
|
|
37
|
+
const busy = [...stats.agents].filter(([, a]) => a.busySince && a.doing);
|
|
38
|
+
const who = busy.map(([id, a]) => `${id}: ${shortTitle(a.doing)}${a.pct ? ` ${a.pct}%` : ""}`);
|
|
39
|
+
if (r.phase === "QA")
|
|
40
|
+
return { needsYou: false, text: `testing the finished build: agents try to break parts they didn't write${r.round ? ` (round ${r.round})` : ""}${who.length ? " ยท " + who.join(" ยท ") : ""}` };
|
|
41
|
+
const done = r.total ? `${r.done} of ${r.total} parts done` : "starting";
|
|
42
|
+
return { needsYou: false, text: `building โ ${done}${r.failed ? `, ${r.failed} stuck` : ""}${who.length ? " ยท " + who.join(" ยท ") : ""}` };
|
|
43
|
+
}
|
|
44
|
+
/** The narrator line, cut to `W` cells: " Now: โฆ ยท nothing needed from you". */
|
|
45
|
+
export function narratorLine(stats, W, now = Date.now()) {
|
|
46
|
+
const n = narration(stats, now);
|
|
47
|
+
if (n.needsYou)
|
|
48
|
+
return truncate(` ${C.yellow("โ " + n.text)}`, W);
|
|
49
|
+
const calm = C.faint(" ยท nothing needed from you");
|
|
50
|
+
const head = ` ${C.bold(C.violet("Now:"))} `;
|
|
51
|
+
// The reassurance stays; the detail gives way.
|
|
52
|
+
const room = W - 2 - 5 - 27;
|
|
53
|
+
const text = n.text.length > room && room > 12 ? n.text.slice(0, room - 1) + "โฆ" : n.text;
|
|
54
|
+
return truncate(`${head}${text}${calm}`, W);
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=narrator.js.map
|
package/dist/ui/paste.js
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
// Pasting at the `huddle โบ` prompt, and the commands that put things on the
|
|
2
|
+
// shared screen (src/room/context.ts).
|
|
3
|
+
//
|
|
4
|
+
// readline fires one "line" per pasted line, so a pasted stack trace used to
|
|
5
|
+
// run as 200 goals. Here the paste is caught BEFORE readline sees it:
|
|
6
|
+
//
|
|
7
|
+
// bracketed paste the terminal wraps a paste in ESC[200~ โฆ ESC[201~ (we turn
|
|
8
|
+
// that on with ESC[?2004h) โ everything inside is one input
|
|
9
|
+
// fallback a terminal without it: text that arrives in one burst
|
|
10
|
+
// (a newline plus more, chunks < 30 ms apart) is one paste
|
|
11
|
+
//
|
|
12
|
+
// A short paste (โค 3 lines, โค 500 chars) goes back to the line as typed, on
|
|
13
|
+
// one line. A long one becomes a screen item; what you type next is the goal.
|
|
14
|
+
import { StringDecoder } from "node:string_decoder";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { RoomDB } from "../room/db.js";
|
|
18
|
+
import { ContextStore, brainSummary, droppedPaths, expandGlob, fmtBytes, isGlob, looksText as looksTextBuf, redactSecrets, splitPathTokens, } from "../room/context.js";
|
|
19
|
+
import { C } from "./term.js";
|
|
20
|
+
export const PASTE_START = "\x1b[200~";
|
|
21
|
+
export const PASTE_END = "\x1b[201~";
|
|
22
|
+
/** Ask the terminal to mark pastes (or stop, before handing it to another program). */
|
|
23
|
+
export function bracketedPaste(on, out = process.stdout) {
|
|
24
|
+
if (out.isTTY)
|
|
25
|
+
out.write(on ? "\x1b[?2004h" : "\x1b[?2004l");
|
|
26
|
+
}
|
|
27
|
+
/** Long enough to be a screen item rather than part of the line. */
|
|
28
|
+
export function isLongPaste(text) {
|
|
29
|
+
const t = text.replace(/\r\n?/g, "\n").replace(/\n+$/, "");
|
|
30
|
+
return t.split("\n").length > 3 || t.length > 500;
|
|
31
|
+
}
|
|
32
|
+
/** A short multi-line paste, as one typed line. */
|
|
33
|
+
export const flatten = (text) => text.replace(/\r\n?|\n/g, " ").replace(/[\x00-\x08\x0B-\x1F\x7F]/g, "").replace(/\s+$/, "");
|
|
34
|
+
/** How many chars at the end of `s` could be the start of `marker`. */
|
|
35
|
+
function partialTail(s, marker) {
|
|
36
|
+
for (let k = Math.min(marker.length - 1, s.length); k > 0; k--)
|
|
37
|
+
if (s.endsWith(marker.slice(0, k)))
|
|
38
|
+
return k;
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Splits terminal input into typed text (passed through to readline) and
|
|
43
|
+
* pastes (handed to onPaste). Pure apart from its timers, so tests feed it
|
|
44
|
+
* chunks directly.
|
|
45
|
+
*/
|
|
46
|
+
export class PasteCatcher {
|
|
47
|
+
o;
|
|
48
|
+
inPaste = false;
|
|
49
|
+
buf = "";
|
|
50
|
+
tail = "";
|
|
51
|
+
burst = "";
|
|
52
|
+
burstTimer = null;
|
|
53
|
+
tailTimer = null;
|
|
54
|
+
constructor(o) {
|
|
55
|
+
this.o = o;
|
|
56
|
+
}
|
|
57
|
+
feed(chunk) {
|
|
58
|
+
if (this.o.enabled && !this.o.enabled() && !this.inPaste) {
|
|
59
|
+
this.flushBurst();
|
|
60
|
+
return this.o.pass(this.takeTail() + chunk);
|
|
61
|
+
}
|
|
62
|
+
let s = this.takeTail() + chunk;
|
|
63
|
+
while (s) {
|
|
64
|
+
if (this.inPaste) {
|
|
65
|
+
const j = s.indexOf(PASTE_END);
|
|
66
|
+
if (j < 0) {
|
|
67
|
+
const k = partialTail(s, PASTE_END);
|
|
68
|
+
this.buf += s.slice(0, s.length - k);
|
|
69
|
+
this.holdTail(s.slice(s.length - k));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this.buf += s.slice(0, j);
|
|
73
|
+
s = s.slice(j + PASTE_END.length);
|
|
74
|
+
this.inPaste = false;
|
|
75
|
+
const text = this.buf;
|
|
76
|
+
this.buf = "";
|
|
77
|
+
if (text)
|
|
78
|
+
this.o.onPaste(text);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const i = s.indexOf(PASTE_START);
|
|
82
|
+
if (i < 0) {
|
|
83
|
+
const k = partialTail(s, PASTE_START);
|
|
84
|
+
if (s.length - k)
|
|
85
|
+
this.typed(s.slice(0, s.length - k));
|
|
86
|
+
this.holdTail(s.slice(s.length - k));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (i > 0)
|
|
90
|
+
this.typed(s.slice(0, i));
|
|
91
|
+
this.flushBurst();
|
|
92
|
+
s = s.slice(i + PASTE_START.length);
|
|
93
|
+
this.inPaste = true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
takeTail() {
|
|
97
|
+
const t = this.tail;
|
|
98
|
+
this.tail = "";
|
|
99
|
+
if (this.tailTimer)
|
|
100
|
+
clearTimeout(this.tailTimer);
|
|
101
|
+
this.tailTimer = null;
|
|
102
|
+
return t;
|
|
103
|
+
}
|
|
104
|
+
/** A lone ESC (the escape key) may be a marker's start: wait a moment, then let it through. */
|
|
105
|
+
holdTail(t) {
|
|
106
|
+
this.tail = t;
|
|
107
|
+
if (!t)
|
|
108
|
+
return;
|
|
109
|
+
this.tailTimer = setTimeout(() => {
|
|
110
|
+
this.tailTimer = null;
|
|
111
|
+
const held = this.tail;
|
|
112
|
+
this.tail = "";
|
|
113
|
+
if (!held)
|
|
114
|
+
return;
|
|
115
|
+
if (this.inPaste)
|
|
116
|
+
this.buf += held;
|
|
117
|
+
else
|
|
118
|
+
this.typed(held);
|
|
119
|
+
}, 25);
|
|
120
|
+
}
|
|
121
|
+
/** The fallback: a newline plus more in one chunk โ or chunks right behind it โ is a paste. */
|
|
122
|
+
typed(t) {
|
|
123
|
+
if (this.burstTimer) {
|
|
124
|
+
this.burst += t;
|
|
125
|
+
this.armBurst();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (t.length > 1 && /[\r\n]/.test(t)) {
|
|
129
|
+
this.burst = t;
|
|
130
|
+
this.armBurst();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.o.pass(t);
|
|
134
|
+
}
|
|
135
|
+
armBurst() {
|
|
136
|
+
if (this.burstTimer)
|
|
137
|
+
clearTimeout(this.burstTimer);
|
|
138
|
+
this.burstTimer = setTimeout(() => this.flushBurst(), this.o.windowMs ?? 30);
|
|
139
|
+
}
|
|
140
|
+
flushBurst() {
|
|
141
|
+
if (this.burstTimer)
|
|
142
|
+
clearTimeout(this.burstTimer);
|
|
143
|
+
this.burstTimer = null;
|
|
144
|
+
const b = this.burst;
|
|
145
|
+
this.burst = "";
|
|
146
|
+
if (!b)
|
|
147
|
+
return;
|
|
148
|
+
// One line + Enter that arrived in one chunk (a laggy terminal) is still typing.
|
|
149
|
+
const body = b.replace(/[\r\n]+$/, "");
|
|
150
|
+
if (/[\r\n]/.test(body))
|
|
151
|
+
this.o.onPaste(body);
|
|
152
|
+
else
|
|
153
|
+
this.o.pass(b);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Put a catcher in front of stdin: every "data" chunk goes through it before
|
|
158
|
+
* readline (and keypress listeners) see anything. Returns the undo.
|
|
159
|
+
*/
|
|
160
|
+
export function interceptStdin(stdin, make) {
|
|
161
|
+
const orig = stdin.emit;
|
|
162
|
+
const decoder = new StringDecoder("utf8");
|
|
163
|
+
const pass = (s) => void orig.call(stdin, "data", Buffer.from(s, "utf8"));
|
|
164
|
+
const catcher = make(pass);
|
|
165
|
+
stdin.emit = function (ev, ...args) {
|
|
166
|
+
if (ev !== "data")
|
|
167
|
+
return orig.apply(this, [ev, ...args]);
|
|
168
|
+
const c = args[0];
|
|
169
|
+
catcher.feed(typeof c === "string" ? c : decoder.write(c));
|
|
170
|
+
return true;
|
|
171
|
+
};
|
|
172
|
+
return () => {
|
|
173
|
+
stdin.emit = orig;
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const q = (s) => `'${s.length > 40 ? s.slice(0, 39) + "โฆ" : s}'`;
|
|
177
|
+
const sizeWord = (i) => (i.lines && (i.kind === "text" || i.kind === "code") ? `${i.lines} line${i.lines === 1 ? "" : "s"}` : fmtBytes(i.bytes));
|
|
178
|
+
/** Secrets found โ ask (or, with nobody to ask, redact) โ save. */
|
|
179
|
+
async function save(store, p, ui) {
|
|
180
|
+
let redact = true;
|
|
181
|
+
if (p.secrets.length) {
|
|
182
|
+
const kinds = [...new Set(p.secrets.map((h) => h.kind))].join(", ");
|
|
183
|
+
const { agents, companies } = ui.audience();
|
|
184
|
+
const who = agents.length ? `${agents.join(", ")} (${companies} compan${companies === 1 ? "y" : "ies"})` : "every agent in the room";
|
|
185
|
+
ui.print(C.yellow(` โ ${p.secrets.length} likely secret${p.secrets.length > 1 ? "s" : ""} in ${q(p.title)}: ${kinds} (line ${p.secrets.slice(0, 3).map((h) => h.line).join(", ")})`));
|
|
186
|
+
if (ui.interactive)
|
|
187
|
+
redact = await ui.yes(` this will be shown to ${who} โ redact?`, true);
|
|
188
|
+
else
|
|
189
|
+
ui.print(C.faint(` redacted before it goes to ${who}`));
|
|
190
|
+
}
|
|
191
|
+
const res = store.commit(p, { by: "you", redact, announce: ui.announce });
|
|
192
|
+
if (res.ok && !res.duplicate && res.item.lines > 200 && ui.brain) {
|
|
193
|
+
// A one-line brain summary for the index โ in the background, never blocking the prompt.
|
|
194
|
+
void brainSummary(store, res.item, ui.brain(), ui.root?.() ?? process.cwd()).catch(() => { });
|
|
195
|
+
}
|
|
196
|
+
return res;
|
|
197
|
+
}
|
|
198
|
+
/** Print what happened to an add; true if the item is on the screen. */
|
|
199
|
+
function report(res, ui, verb) {
|
|
200
|
+
if (!res.ok) {
|
|
201
|
+
ui.print(C.red(` โ ${res.reason}`));
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
const i = res.item;
|
|
205
|
+
if (res.duplicate) {
|
|
206
|
+
ui.print(` ๐ already on the screen: ${i.id} ${q(i.title)}`);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
// "๐ pasted 214 lines (ctx-3 'TypeErrorโฆ')" ยท "๐ added 'shot.png' (ctx-4 ยท image ยท 412 KB ยท 1280ร720)"
|
|
210
|
+
if (verb === "added")
|
|
211
|
+
ui.print(` ๐ added ${q(i.title)} ${C.faint(`(${i.id} ยท ${i.kind} ยท ${i.lines ? sizeWord(i) + ", " : ""}${fmtBytes(i.bytes)}${i.summary ? " ยท " + i.summary.slice(0, 60) : ""})`)}`);
|
|
212
|
+
else
|
|
213
|
+
ui.print(` ๐ ${verb} ${sizeWord(i)} ${C.faint(`(${i.id} ${q(i.title)})`)}`);
|
|
214
|
+
if (res.redacted)
|
|
215
|
+
ui.print(C.faint(` ${res.redacted} secret${res.redacted > 1 ? "s" : ""} redacted`));
|
|
216
|
+
for (const w of res.warnings ?? [])
|
|
217
|
+
ui.print(C.faint(` ${w}`));
|
|
218
|
+
if (res.announced?.length)
|
|
219
|
+
ui.print(C.faint(` told ${res.announced.join(", ")} (working now)`));
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
/** A long paste โ a screen item. Returns false if it should go back to the line instead. */
|
|
223
|
+
export async function pasteToScreen(text, ui) {
|
|
224
|
+
const db = await ui.db();
|
|
225
|
+
if (!db) {
|
|
226
|
+
ui.print(C.yellow(" ! pastes go on a repo's shared screen โ cd into a git repo first"));
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
const store = ContextStore.open(db);
|
|
230
|
+
const p = store.prepareText(text);
|
|
231
|
+
if ("ok" in p) {
|
|
232
|
+
ui.print(C.red(` โ ${p.reason}`));
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
const res = await save(store, p, ui);
|
|
236
|
+
report(res, ui, "pasted");
|
|
237
|
+
if (res.ok && !res.duplicate)
|
|
238
|
+
ui.print(C.faint(" type your goal โ every agent sees it on the shared screen, not in N copies"));
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
/** A dropped file path (or several): the whole line is existing absolute paths. */
|
|
242
|
+
export function asDrop(text) {
|
|
243
|
+
return droppedPaths(text.replace(/\r?\n/g, " "));
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* `add a login page` is a goal; `add ./log.txt`, `add *.png`, `add ~/shot.png`,
|
|
247
|
+
* `add notes.md` (when it exists) add files. A bare missing name ("add
|
|
248
|
+
* README.md") is a goal: the user wants it written.
|
|
249
|
+
*/
|
|
250
|
+
export function looksLikePaths(rest, cwd = process.cwd()) {
|
|
251
|
+
const toks = splitPathTokens(rest);
|
|
252
|
+
if (!toks.length)
|
|
253
|
+
return false;
|
|
254
|
+
return toks.every((t) => isGlob(t) || /[\\/~]|^\./.test(t) || (/\.\w{1,8}$/.test(t) && fs.existsSync(path.resolve(cwd, t))));
|
|
255
|
+
}
|
|
256
|
+
/** A Room event as a line for the prompt and/or the live view (null = not a screen event). */
|
|
257
|
+
export function screenEvent(e) {
|
|
258
|
+
if (!e.type.startsWith("context."))
|
|
259
|
+
return null;
|
|
260
|
+
let p = {};
|
|
261
|
+
try {
|
|
262
|
+
p = JSON.parse(e.payload || "{}");
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
const t = q(String(p.title ?? ""));
|
|
268
|
+
if (e.type === "context.note") {
|
|
269
|
+
const text = `๐ ${C.bold(p.by)} on ${p.id} ${C.faint(t)}: ${p.body}`;
|
|
270
|
+
return { print: p.by === "you" ? undefined : ` ${text}`, dash: { text } };
|
|
271
|
+
}
|
|
272
|
+
if (e.type === "context.added" && p.by !== "you") {
|
|
273
|
+
const text = `๐ ${C.bold(p.by)} put ${p.id} on the screen: ${t}`;
|
|
274
|
+
return { print: ` ${text}`, dash: { text } };
|
|
275
|
+
}
|
|
276
|
+
if (e.type === "context.read" && e.agent_id)
|
|
277
|
+
return { dash: { agent: e.agent_id, text: `๐ read ${p.id} ${t}${p.from ? ` lines ${p.from}-${p.to}` : ""}` } };
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* `add <pathโฆ|glob>`: files, folders (their text files, as one item) and
|
|
282
|
+
* globs (more than 5 matches โ one item). Returns how many failed.
|
|
283
|
+
*/
|
|
284
|
+
export async function addPaths(args, ui, cwd = process.cwd()) {
|
|
285
|
+
const db = await ui.db();
|
|
286
|
+
if (!db) {
|
|
287
|
+
ui.print(C.red(" โ open a git repo first") + C.faint(" โ the shared screen belongs to a project"));
|
|
288
|
+
return args.length || 1;
|
|
289
|
+
}
|
|
290
|
+
const store = ContextStore.open(db);
|
|
291
|
+
if (!args.length)
|
|
292
|
+
ui.print(C.faint(" usage: add <file|folder|glob> โฆ โ or just drag a file into this window"));
|
|
293
|
+
let failed = 0;
|
|
294
|
+
for (const a of args) {
|
|
295
|
+
// "shot [1].png" that exists is a file, not a pattern.
|
|
296
|
+
if (isGlob(a) && !fs.existsSync(path.resolve(cwd, a))) {
|
|
297
|
+
const hits = expandGlob(a, cwd);
|
|
298
|
+
if (!hits.length) {
|
|
299
|
+
ui.print(C.red(` โ nothing matches ${a}`));
|
|
300
|
+
failed++;
|
|
301
|
+
}
|
|
302
|
+
else if (hits.length > 5) {
|
|
303
|
+
const p = store.prepareBundle(a, cwd, hits);
|
|
304
|
+
if ("ok" in p) {
|
|
305
|
+
ui.print(C.red(` โ ${p.reason}`));
|
|
306
|
+
failed++;
|
|
307
|
+
}
|
|
308
|
+
else if (!report(await save(store, p, ui), ui, "added"))
|
|
309
|
+
failed++;
|
|
310
|
+
}
|
|
311
|
+
else
|
|
312
|
+
for (const h of hits)
|
|
313
|
+
if (!(await addOne(store, h, ui, cwd)))
|
|
314
|
+
failed++;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (!(await addOne(store, a, ui, cwd)))
|
|
318
|
+
failed++;
|
|
319
|
+
}
|
|
320
|
+
return failed;
|
|
321
|
+
}
|
|
322
|
+
async function addOne(store, p, ui, cwd) {
|
|
323
|
+
const prep = store.preparePath(p, cwd);
|
|
324
|
+
if ("ok" in prep) {
|
|
325
|
+
ui.print(C.red(` โ ${prep.reason}`));
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
return report(await save(store, prep, ui), ui, "added");
|
|
329
|
+
}
|
|
330
|
+
/** `ctx` / `context`: list ยท show <id> ยท rm <idโฆ> ยท note <id> <text> ยท add <pathโฆ> ยท clear */
|
|
331
|
+
export async function ctxCommand(arg, ui) {
|
|
332
|
+
const db = await ui.db();
|
|
333
|
+
if (!db)
|
|
334
|
+
return ui.print(C.red(" โ open a git repo first") + C.faint(" โ the shared screen belongs to a project"));
|
|
335
|
+
const store = ContextStore.open(db);
|
|
336
|
+
const [sub, ...rest] = arg.trim().split(/\s+/).filter(Boolean);
|
|
337
|
+
if (!sub || sub === "ls" || sub === "list") {
|
|
338
|
+
const items = store.list();
|
|
339
|
+
if (!items.length)
|
|
340
|
+
return ui.print(C.faint(" nothing on the shared screen โ paste something long, drag a file in, or `add <file>`"));
|
|
341
|
+
ui.print(`\n ${C.violet("SCREEN")} ${C.faint(`ยท ${items.length} item${items.length > 1 ? "s" : ""} ยท ${fmtBytes(store.totalBytes())} ยท agents read these on demand`)}`);
|
|
342
|
+
for (const i of items) {
|
|
343
|
+
ui.print(` ${C.bold(i.id.padEnd(6))} ${i.kind.padEnd(5)} ${q(i.title)} ${C.faint(`ยท ${sizeWord(i)} ยท by ${i.added_by}${i.notes.length ? ` ยท ${i.notes.length} note${i.notes.length > 1 ? "s" : ""}` : ""}`)}`);
|
|
344
|
+
if (i.brain_summary || i.summary)
|
|
345
|
+
ui.print(C.faint(` ${(i.brain_summary || i.summary).slice(0, 110)}`));
|
|
346
|
+
for (const n of i.notes.slice(-2))
|
|
347
|
+
ui.print(` ๐ ${C.bold(n.by)} ${n.body.slice(0, 110)}`);
|
|
348
|
+
}
|
|
349
|
+
return ui.print(C.faint("\n ctx show <id> ยท ctx rm <id> ยท ctx note <id> <text> ยท add <file>"));
|
|
350
|
+
}
|
|
351
|
+
if (sub === "add")
|
|
352
|
+
return void (await addPaths(splitPathTokens(rest.join(" ")), ui));
|
|
353
|
+
if (sub === "clear") {
|
|
354
|
+
const items = store.list();
|
|
355
|
+
if (!items.length)
|
|
356
|
+
return ui.print(C.faint(" the screen is already empty"));
|
|
357
|
+
if (ui.interactive && !(await ui.yes(` take all ${items.length} items off the screen?`, false)))
|
|
358
|
+
return;
|
|
359
|
+
for (const i of items)
|
|
360
|
+
store.remove(i.id);
|
|
361
|
+
return ui.print(` ${C.green("โ")} screen cleared`);
|
|
362
|
+
}
|
|
363
|
+
if (sub === "rm" || sub === "remove" || sub === "del") {
|
|
364
|
+
if (!rest.length)
|
|
365
|
+
return ui.print(C.faint(" usage: ctx rm <id>"));
|
|
366
|
+
for (const id of rest)
|
|
367
|
+
ui.print(store.remove(id) ? ` ${C.green("โ")} ${id} is off the screen` : C.red(` โ no item ${id}`));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (sub === "note") {
|
|
371
|
+
const [id, ...words] = rest;
|
|
372
|
+
if (!id || !words.length)
|
|
373
|
+
return ui.print(C.faint(" usage: ctx note <id> <what everyone should know about it>"));
|
|
374
|
+
const n = store.note(id, words.join(" "), "you");
|
|
375
|
+
return ui.print("error" in n ? C.red(` โ ${n.error}`) : ` ๐ pinned on ${id}`);
|
|
376
|
+
}
|
|
377
|
+
const id = sub === "show" || sub === "cat" ? rest[0] : sub;
|
|
378
|
+
const it = id ? store.get(id) : undefined;
|
|
379
|
+
if (!it)
|
|
380
|
+
return ui.print(C.red(` โ no item ${id ?? ""}`) + C.faint(" โ `ctx` lists them"));
|
|
381
|
+
ui.print(`\n ${C.bold(it.id)} ${it.kind} ${q(it.title)} ${C.faint(`ยท ${sizeWord(it)} ยท ${fmtBytes(it.bytes)} ยท by ${it.added_by}`)}`);
|
|
382
|
+
if (it.summary)
|
|
383
|
+
ui.print(C.faint(` ${it.summary}`));
|
|
384
|
+
if (it.sections.length)
|
|
385
|
+
ui.print(C.faint(` sections: ${it.sections.slice(0, 8).map((s, k) => `${k + 1}. ${s.title} (${s.from}-${s.to})`).join(" ยท ")}${it.sections.length > 8 ? " โฆ" : ""}`));
|
|
386
|
+
const text = store.textOf(it);
|
|
387
|
+
if (text === null)
|
|
388
|
+
ui.print(C.faint(` file: ${store.pathOf(it)}`));
|
|
389
|
+
else {
|
|
390
|
+
const lines = text.split("\n");
|
|
391
|
+
for (const l of lines.slice(0, 30))
|
|
392
|
+
ui.print(C.faint(" โ ") + l.slice(0, 200));
|
|
393
|
+
if (lines.length > 30)
|
|
394
|
+
ui.print(C.faint(` โ โฆ ${lines.length - 30} more lines`));
|
|
395
|
+
}
|
|
396
|
+
for (const n of it.notes)
|
|
397
|
+
ui.print(` ๐ ${C.bold(n.by)} ${n.body}`);
|
|
398
|
+
}
|
|
399
|
+
// ---- one-shot runs: huddle join "<goal>" --with f --with g, and stdin -----------
|
|
400
|
+
/** `--with a --with=b` โ ["a", "b"], and the args without them. */
|
|
401
|
+
export function takeWith(args) {
|
|
402
|
+
const files = [];
|
|
403
|
+
const rest = [];
|
|
404
|
+
for (let i = 0; i < args.length; i++) {
|
|
405
|
+
const a = args[i];
|
|
406
|
+
if (a === "--with" && i + 1 < args.length)
|
|
407
|
+
files.push(args[++i]);
|
|
408
|
+
else if (a.startsWith("--with="))
|
|
409
|
+
files.push(a.slice(7));
|
|
410
|
+
else
|
|
411
|
+
rest.push(a);
|
|
412
|
+
}
|
|
413
|
+
return { files, rest };
|
|
414
|
+
}
|
|
415
|
+
/** Piped stdin (`cat log.txt | huddle join "fix this"`). Waits briefly for a first chunk, so an idle pipe never hangs a run. */
|
|
416
|
+
export function readPipedStdin(stdin = process.stdin, firstMs = 300) {
|
|
417
|
+
if (stdin.isTTY)
|
|
418
|
+
return Promise.resolve("");
|
|
419
|
+
return new Promise((resolve) => {
|
|
420
|
+
let data = "";
|
|
421
|
+
let started = false;
|
|
422
|
+
const done = () => {
|
|
423
|
+
clearTimeout(idle);
|
|
424
|
+
stdin.off("data", onData);
|
|
425
|
+
stdin.off("end", done);
|
|
426
|
+
stdin.off("error", done);
|
|
427
|
+
stdin.pause();
|
|
428
|
+
resolve(data);
|
|
429
|
+
};
|
|
430
|
+
const onData = (c) => {
|
|
431
|
+
started = true;
|
|
432
|
+
data += c.toString();
|
|
433
|
+
};
|
|
434
|
+
const idle = setTimeout(() => !started && done(), firstMs);
|
|
435
|
+
stdin.on("data", onData);
|
|
436
|
+
stdin.once("end", done);
|
|
437
|
+
stdin.once("error", done);
|
|
438
|
+
stdin.resume();
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
/** Text from a pipe (`cat err.log | huddle add -`) as one item. True if it's on the screen. */
|
|
442
|
+
export async function addPiped(store, text, ui, opts = {}) {
|
|
443
|
+
const p = store.prepareText(text, { source: "stdin", title: opts.title });
|
|
444
|
+
if ("ok" in p) {
|
|
445
|
+
ui.print(C.red(` โ stdin: ${p.reason}`));
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
return report(await save(store, p, ui), ui, "piped in");
|
|
449
|
+
}
|
|
450
|
+
/** Put a one-shot run's --with files and stdin on the screen before planning. */
|
|
451
|
+
export async function addForRun(dbFile, files, piped, print, cwd = process.cwd()) {
|
|
452
|
+
if (!files.length && !piped.trim())
|
|
453
|
+
return 0;
|
|
454
|
+
const db = new RoomDB(dbFile);
|
|
455
|
+
const ui = { print, yes: async () => true, db: async () => db, audience: () => ({ agents: [], companies: 0 }), interactive: false };
|
|
456
|
+
let failed = 0;
|
|
457
|
+
try {
|
|
458
|
+
const store = ContextStore.open(db);
|
|
459
|
+
if (piped.trim() && !(await addPiped(store, piped, ui)))
|
|
460
|
+
failed++;
|
|
461
|
+
failed += files.length ? await addPaths(files, ui, cwd) : 0;
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
db.close();
|
|
465
|
+
}
|
|
466
|
+
return failed;
|
|
467
|
+
}
|
|
468
|
+
// ---- a teammate's side (`huddle join <address> <CODE>`) ----------------------------
|
|
469
|
+
/**
|
|
470
|
+
* `add <file>` / `ctx` typed by a teammate: text files go to the host's screen
|
|
471
|
+
* over the shared API (the body cap there is 256 KB); `ctx` lists the screen.
|
|
472
|
+
*/
|
|
473
|
+
export async function remoteScreen(request, line, say) {
|
|
474
|
+
const [cmd] = line.split(/\s+/);
|
|
475
|
+
if (cmd === "ctx" || cmd === "context") {
|
|
476
|
+
const r = await request("GET", "/context");
|
|
477
|
+
if (r.status !== 200)
|
|
478
|
+
return say(C.red(` โ ${r.body?.error ?? "couldn't read the screen"}`));
|
|
479
|
+
const items = r.body.items ?? [];
|
|
480
|
+
if (!items.length)
|
|
481
|
+
return say(C.faint(" nothing on the shared screen yet โ `add <file>` to put a text file up"));
|
|
482
|
+
for (const i of items) {
|
|
483
|
+
say(` ${C.bold(String(i.id).padEnd(6))} ${String(i.kind).padEnd(5)} ${q(String(i.title))} ${C.faint(`ยท ${i.size} ยท by ${i.added_by}`)}`);
|
|
484
|
+
for (const n of (i.notes ?? []).slice(-2))
|
|
485
|
+
say(` ๐ ${n}`);
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
for (const f of splitPathTokens(line.slice(cmd.length))) {
|
|
490
|
+
let text;
|
|
491
|
+
try {
|
|
492
|
+
const buf = fs.readFileSync(f);
|
|
493
|
+
if (buf.length > 200 * 1024) {
|
|
494
|
+
say(C.red(` โ ${path.basename(f)} is ${fmtBytes(buf.length)} โ teammates can send text up to 200 KB; ask the host to add it`));
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (!looksTextBuf(buf)) {
|
|
498
|
+
say(C.red(` โ ${path.basename(f)} isn't text โ screenshots and PDFs go up from the host's machine`));
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
text = buf.toString("utf8");
|
|
502
|
+
}
|
|
503
|
+
catch (e) {
|
|
504
|
+
say(C.red(` โ ${e?.code === "ENOENT" ? "no such file: " + f : e?.code === "EACCES" ? "permission denied: " + f : String(e?.message ?? e)}`));
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
// Secrets never leave this machine: redacted here, before the send.
|
|
508
|
+
const red = redactSecrets(text);
|
|
509
|
+
if (red.count)
|
|
510
|
+
say(C.yellow(` โ redacted ${red.count} likely secret${red.count > 1 ? "s" : ""} in ${path.basename(f)} before sending`));
|
|
511
|
+
const r = await request("POST", "/context/add", { text: red.text, title: path.basename(f) });
|
|
512
|
+
say(r.status === 200 ? ` ๐ ${r.body.duplicate ? "already on the screen" : "on the screen"}: ${r.body.id} ${q(path.basename(f))}` : C.red(` โ ${r.body?.error ?? "not added"}`));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
//# sourceMappingURL=paste.js.map
|