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,1285 @@
|
|
|
1
|
+
// The shared screen: everything the user pastes, drops or uploads, kept ONCE
|
|
2
|
+
// in the Room for every agent to look at — instead of N copies stuffed into N
|
|
3
|
+
// prompts. Prompts carry only the index (one line per item); agents open what
|
|
4
|
+
// they need with huddle_context_read, search it, and pin notes on items that
|
|
5
|
+
// everyone (agents and people) sees.
|
|
6
|
+
//
|
|
7
|
+
// <repo>/.huddle/context/ctx-3.txt the item itself (.huddle is git-ignored)
|
|
8
|
+
// <repo>/.huddle/context/index.json a mirror of the rows, for processes that
|
|
9
|
+
// only know the repo root (launcher, planner)
|
|
10
|
+
// context_item / context_note / context_link rows in the Room db (the truth)
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { spawnToolSync } from "../platform.js";
|
|
16
|
+
/** Per-item and total caps. Env vars raise them (MB). */
|
|
17
|
+
export const CAPS = {
|
|
18
|
+
textBytes: mb("HUDDLE_CTX_MAX_TEXT_MB", 2),
|
|
19
|
+
fileBytes: mb("HUDDLE_CTX_MAX_FILE_MB", 25),
|
|
20
|
+
totalBytes: mb("HUDDLE_CTX_MAX_TOTAL_MB", 200),
|
|
21
|
+
items: 200,
|
|
22
|
+
/** A folder or glob becomes one bundle item: at most this many files go in. */
|
|
23
|
+
folderFiles: 300,
|
|
24
|
+
};
|
|
25
|
+
function mb(env, def) {
|
|
26
|
+
const v = Number(process.env[env]);
|
|
27
|
+
return Math.round((Number.isFinite(v) && v > 0 ? v : def) * 1024 * 1024);
|
|
28
|
+
}
|
|
29
|
+
/** What one read may return: enough to work with, small enough to not drown a prompt. */
|
|
30
|
+
export const READ_MAX_LINES = 400;
|
|
31
|
+
const READ_MAX_CHARS = 60_000;
|
|
32
|
+
const LINE_MAX_CHARS = 4_000;
|
|
33
|
+
/** The index in a prompt never grows past this, however many items there are. */
|
|
34
|
+
export const INDEX_CAP = 1_500;
|
|
35
|
+
/** Items this small may be shown inline (once) in a task's prompt. */
|
|
36
|
+
export const INLINE_MAX = 1_500;
|
|
37
|
+
const SCHEMA = /* sql */ `
|
|
38
|
+
CREATE TABLE IF NOT EXISTS context_item (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
seq INTEGER NOT NULL,
|
|
41
|
+
kind TEXT NOT NULL,
|
|
42
|
+
title TEXT NOT NULL,
|
|
43
|
+
file TEXT NOT NULL,
|
|
44
|
+
mime TEXT NOT NULL DEFAULT '',
|
|
45
|
+
bytes INTEGER NOT NULL,
|
|
46
|
+
lines INTEGER NOT NULL DEFAULT 0,
|
|
47
|
+
sha256 TEXT NOT NULL,
|
|
48
|
+
added_by TEXT NOT NULL,
|
|
49
|
+
created_at INTEGER NOT NULL,
|
|
50
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
51
|
+
brain_summary TEXT NOT NULL DEFAULT '',
|
|
52
|
+
sections TEXT NOT NULL DEFAULT '[]',
|
|
53
|
+
source TEXT NOT NULL DEFAULT '',
|
|
54
|
+
removed_at INTEGER
|
|
55
|
+
);
|
|
56
|
+
CREATE TABLE IF NOT EXISTS context_note (
|
|
57
|
+
id TEXT PRIMARY KEY,
|
|
58
|
+
item_id TEXT NOT NULL,
|
|
59
|
+
by TEXT NOT NULL,
|
|
60
|
+
body TEXT NOT NULL,
|
|
61
|
+
created_at INTEGER NOT NULL
|
|
62
|
+
);
|
|
63
|
+
CREATE TABLE IF NOT EXISTS context_link (
|
|
64
|
+
task_id TEXT NOT NULL,
|
|
65
|
+
item_id TEXT NOT NULL,
|
|
66
|
+
PRIMARY KEY (task_id, item_id)
|
|
67
|
+
);
|
|
68
|
+
`;
|
|
69
|
+
// ---- kinds, languages, cheap summaries ---------------------------------------
|
|
70
|
+
const IMAGE_EXT = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" };
|
|
71
|
+
const TEXT_EXT = new Set(["txt", "log", "md", "markdown", "rst", "csv", "tsv", "json", "jsonl", "yaml", "yml", "toml", "ini", "cfg", "conf", "xml", "html", "htm", "svg", "env", "diff", "patch", "out", "err"]);
|
|
72
|
+
const CODE_LANG = {
|
|
73
|
+
js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "javascript", ts: "typescript", tsx: "typescript", py: "python", rb: "ruby", go: "go", rs: "rust",
|
|
74
|
+
java: "java", kt: "kotlin", swift: "swift", c: "c", h: "c", cc: "c++", cpp: "c++", hpp: "c++", cs: "c#", php: "php", sh: "shell", bash: "shell", zsh: "shell",
|
|
75
|
+
ps1: "powershell", sql: "sql", css: "css", scss: "scss", vue: "vue", svelte: "svelte", lua: "lua", dart: "dart", scala: "scala", ex: "elixir", exs: "elixir",
|
|
76
|
+
r: "r", m: "objective-c", zig: "zig", graphql: "graphql", proto: "protobuf", tf: "terraform", dockerfile: "dockerfile", makefile: "makefile",
|
|
77
|
+
};
|
|
78
|
+
const TEXT_LANG = { md: "markdown", markdown: "markdown", csv: "csv", tsv: "tsv", json: "json", jsonl: "jsonl", yaml: "yaml", yml: "yaml", toml: "toml", xml: "xml", html: "html", htm: "html", svg: "svg", log: "log", diff: "diff", patch: "diff" };
|
|
79
|
+
const extOf = (name) => {
|
|
80
|
+
const base = path.basename(name).toLowerCase();
|
|
81
|
+
if (base === "dockerfile" || base === "makefile")
|
|
82
|
+
return base;
|
|
83
|
+
const i = base.lastIndexOf(".");
|
|
84
|
+
return i > 0 ? base.slice(i + 1) : "";
|
|
85
|
+
};
|
|
86
|
+
/** Does this buffer look like text? No NULs, valid UTF-8, few control chars. */
|
|
87
|
+
export function looksText(buf) {
|
|
88
|
+
const head = buf.subarray(0, 8192);
|
|
89
|
+
if (head.includes(0))
|
|
90
|
+
return false;
|
|
91
|
+
const s = head.toString("utf8");
|
|
92
|
+
// A cut multi-byte char at the 8 KB edge is fine; replacement chars elsewhere aren't.
|
|
93
|
+
const bad = (s.slice(0, -4).match(/\uFFFD/g) ?? []).length;
|
|
94
|
+
const ctrl = (s.match(/[\x00-\x08\x0E-\x1A\x1C-\x1F]/g) ?? []).length;
|
|
95
|
+
return bad === 0 && ctrl <= s.length * 0.01;
|
|
96
|
+
}
|
|
97
|
+
/** Guess what a pasted text is, for the index line. */
|
|
98
|
+
export function guessLang(text) {
|
|
99
|
+
const t = text.trimStart();
|
|
100
|
+
if (/^(Traceback \(most recent call last\)|\w*(Error|Exception)\b.*\n\s+at |.*\n\s+at [\w.<>$]+ \()/m.test(text) || /\n\s+at .+:\d+:\d+\)?$/m.test(text))
|
|
101
|
+
return "stack trace";
|
|
102
|
+
if (/^[{[]/.test(t)) {
|
|
103
|
+
try {
|
|
104
|
+
JSON.parse(t);
|
|
105
|
+
return "json";
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* not json */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (/^diff --git |^@@ .* @@/m.test(text))
|
|
112
|
+
return "diff";
|
|
113
|
+
if (/^#{1,6} \S/m.test(text) || /^```/m.test(text))
|
|
114
|
+
return "markdown";
|
|
115
|
+
const lines = text.split("\n");
|
|
116
|
+
const codey = lines.filter((l) => /[;{}]\s*$|^\s*(import|export|from|def|class|function|const|let|var|return|public|private|func|fn|package|#include)\b/.test(l)).length;
|
|
117
|
+
if (codey >= Math.max(2, lines.length * 0.25))
|
|
118
|
+
return "code";
|
|
119
|
+
if (lines.length > 3 && lines.filter((l) => /^\d{4}-\d\d-\d\d|^\[?\d\d:\d\d:\d\d|\b(INFO|WARN|ERROR|DEBUG)\b/.test(l)).length >= lines.length * 0.3)
|
|
120
|
+
return "log";
|
|
121
|
+
return "text";
|
|
122
|
+
}
|
|
123
|
+
/** Split long text into sections: markdown headings, code fences, or ~200-line chunks. */
|
|
124
|
+
export function sectionsOf(text, fileHeaders = false) {
|
|
125
|
+
const lines = text.split("\n");
|
|
126
|
+
const n = lines.length;
|
|
127
|
+
const cuts = [];
|
|
128
|
+
let fence = false;
|
|
129
|
+
lines.forEach((l, i) => {
|
|
130
|
+
if (fileHeaders) {
|
|
131
|
+
const m = /^===== (.+) =====$/.exec(l);
|
|
132
|
+
if (m)
|
|
133
|
+
cuts.push({ title: m[1], at: i + 1 });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (/^```/.test(l)) {
|
|
137
|
+
if (!fence)
|
|
138
|
+
cuts.push({ title: `code block${l.slice(3).trim() ? " (" + l.slice(3).trim() + ")" : ""}`, at: i + 1 });
|
|
139
|
+
fence = !fence;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const h = !fence && /^(#{1,3}) (.+)/.exec(l);
|
|
143
|
+
if (h)
|
|
144
|
+
cuts.push({ title: h[2].trim().slice(0, 80), at: i + 1 });
|
|
145
|
+
});
|
|
146
|
+
let out = [];
|
|
147
|
+
if (cuts.length) {
|
|
148
|
+
if (cuts[0].at > 1)
|
|
149
|
+
cuts.unshift({ title: "(top)", at: 1 });
|
|
150
|
+
out = cuts.map((c, k) => ({ title: c.title, from: c.at, to: (cuts[k + 1]?.at ?? n + 1) - 1 }));
|
|
151
|
+
}
|
|
152
|
+
// Too few cuts for a long text, or none: fixed chunks keep every read under the cap.
|
|
153
|
+
if (!out.length || out.some((s) => s.to - s.from + 1 > 200)) {
|
|
154
|
+
if (n <= 200 && !out.length)
|
|
155
|
+
return [];
|
|
156
|
+
const chunked = [];
|
|
157
|
+
for (const s of out.length ? out : [{ title: "lines", from: 1, to: n }]) {
|
|
158
|
+
if (s.to - s.from + 1 <= 200) {
|
|
159
|
+
chunked.push(s);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
for (let a = s.from; a <= s.to; a += 200)
|
|
163
|
+
chunked.push({ title: `${s.title === "lines" ? "lines" : s.title + " ·"} ${a}-${Math.min(s.to, a + 199)}`, from: a, to: Math.min(s.to, a + 199) });
|
|
164
|
+
}
|
|
165
|
+
out = chunked;
|
|
166
|
+
}
|
|
167
|
+
return out.slice(0, 200);
|
|
168
|
+
}
|
|
169
|
+
/** Width × height from the image header — cheap, no decoder. */
|
|
170
|
+
export function imageSize(buf, mime) {
|
|
171
|
+
try {
|
|
172
|
+
if (mime === "image/png" && buf.length >= 24 && buf.toString("ascii", 1, 4) === "PNG")
|
|
173
|
+
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
|
|
174
|
+
if (mime === "image/gif" && buf.length >= 10)
|
|
175
|
+
return { w: buf.readUInt16LE(6), h: buf.readUInt16LE(8) };
|
|
176
|
+
if (mime === "image/webp" && buf.length >= 30 && buf.toString("ascii", 12, 16) === "VP8X")
|
|
177
|
+
return { w: 1 + buf.readUIntLE(24, 3), h: 1 + buf.readUIntLE(27, 3) };
|
|
178
|
+
if (mime === "image/jpeg") {
|
|
179
|
+
let i = 2;
|
|
180
|
+
while (i + 9 < buf.length) {
|
|
181
|
+
if (buf[i] !== 0xff)
|
|
182
|
+
return null;
|
|
183
|
+
const marker = buf[i + 1];
|
|
184
|
+
const len = buf.readUInt16BE(i + 2);
|
|
185
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker))
|
|
186
|
+
return { w: buf.readUInt16BE(i + 7), h: buf.readUInt16BE(i + 5) };
|
|
187
|
+
i += 2 + len;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
/* a broken header is just "no size" */
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const human = (n) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(n < 10 * 1024 ? 1 : 0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
|
|
197
|
+
export const fmtBytes = human;
|
|
198
|
+
/** The deterministic summary: language, headings / symbols / columns, the first error. */
|
|
199
|
+
export function summarize(kind, text, lang, extra = "") {
|
|
200
|
+
if (!text)
|
|
201
|
+
return [lang, extra].filter(Boolean).join(" · ");
|
|
202
|
+
const lines = text.split("\n");
|
|
203
|
+
const bits = [];
|
|
204
|
+
if (lang)
|
|
205
|
+
bits.push(lang);
|
|
206
|
+
if (lang === "markdown") {
|
|
207
|
+
const hs = lines.filter((l) => /^#{1,3} \S/.test(l)).map((l) => l.replace(/^#+ /, "").trim());
|
|
208
|
+
if (hs.length)
|
|
209
|
+
bits.push("headings: " + hs.slice(0, 6).join(" / ") + (hs.length > 6 ? ` (+${hs.length - 6})` : ""));
|
|
210
|
+
}
|
|
211
|
+
else if (lang === "csv" || lang === "tsv") {
|
|
212
|
+
bits.push(`columns: ${lines[0].slice(0, 120)}`, `${Math.max(0, lines.filter(Boolean).length - 1)} rows`);
|
|
213
|
+
}
|
|
214
|
+
else if (lang === "json") {
|
|
215
|
+
try {
|
|
216
|
+
const j = JSON.parse(text);
|
|
217
|
+
bits.push(Array.isArray(j) ? `array of ${j.length}` : "keys: " + Object.keys(j).slice(0, 8).join(", "));
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
/* not one JSON value */
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
else if (kind === "code" || lang === "code") {
|
|
224
|
+
const syms = [...text.matchAll(/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|class|def|func|fn|interface|type|struct|enum)\s+([A-Za-z_$][\w$]*)/gm)].map((m) => m[1]);
|
|
225
|
+
if (syms.length)
|
|
226
|
+
bits.push("defines: " + [...new Set(syms)].slice(0, 8).join(", "));
|
|
227
|
+
}
|
|
228
|
+
else if (lang === "stack trace" || lang === "log") {
|
|
229
|
+
const err = lines.find((l) => /error|exception|failed|fatal|panic/i.test(l));
|
|
230
|
+
if (err)
|
|
231
|
+
bits.push(err.trim().slice(0, 120));
|
|
232
|
+
}
|
|
233
|
+
if (extra)
|
|
234
|
+
bits.push(extra);
|
|
235
|
+
return bits.join(" · ").slice(0, 300);
|
|
236
|
+
}
|
|
237
|
+
const SECRET_PATTERNS = [
|
|
238
|
+
{ kind: "private key", re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g },
|
|
239
|
+
{ kind: "Anthropic key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
240
|
+
{ kind: "OpenAI key", re: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/g },
|
|
241
|
+
{ kind: "AWS access key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
|
|
242
|
+
{ kind: "AWS secret", re: /\baws_secret_access_key\s*[=:]\s*["']?[A-Za-z0-9/+=]{40}["']?/gi },
|
|
243
|
+
{ kind: "GitHub token", re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})\b/g },
|
|
244
|
+
{ kind: "Slack token", re: /\bxox[abprse]-[A-Za-z0-9-]{10,}/g },
|
|
245
|
+
{ kind: "Google API key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
246
|
+
{ kind: "Stripe key", re: /\b(?:sk|rk)_live_[0-9A-Za-z]{16,}\b/g },
|
|
247
|
+
// .env-style lines: NAME_KEY=value, NAME_TOKEN: value, PASSWORD=…
|
|
248
|
+
{ kind: ".env secret", re: /^[ \t]*(?:export[ \t]+)?[A-Za-z_][A-Za-z0-9_]*(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD|CREDENTIALS?|AUTH)[A-Za-z0-9_]*[ \t]*[=:][ \t]*["']?[^\s"'#]{6,}["']?/gim },
|
|
249
|
+
];
|
|
250
|
+
const mask = (s) => (s.length <= 10 ? "****" : `${s.slice(0, 6)}…${s.slice(-2)}`);
|
|
251
|
+
/** Likely secrets in a text: API keys, tokens, private keys, .env lines. */
|
|
252
|
+
export function scanSecrets(text) {
|
|
253
|
+
const hits = [];
|
|
254
|
+
const taken = [];
|
|
255
|
+
for (const { kind, re } of SECRET_PATTERNS) {
|
|
256
|
+
for (const m of text.matchAll(re)) {
|
|
257
|
+
const a = m.index ?? 0;
|
|
258
|
+
const b = a + m[0].length;
|
|
259
|
+
if (taken.some(([x, y]) => a < y && b > x))
|
|
260
|
+
continue; // one hit per span (sk-ant- is not also an OpenAI key)
|
|
261
|
+
taken.push([a, b]);
|
|
262
|
+
hits.push({ kind, line: text.slice(0, a).split("\n").length, preview: mask(m[0].split("\n")[0].trim()) });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return hits.sort((x, y) => x.line - y.line);
|
|
266
|
+
}
|
|
267
|
+
/** Replace every likely secret with [REDACTED:<kind>]. .env lines keep their name. */
|
|
268
|
+
export function redactSecrets(text) {
|
|
269
|
+
let count = 0;
|
|
270
|
+
let out = text;
|
|
271
|
+
for (const { kind, re } of SECRET_PATTERNS) {
|
|
272
|
+
out = out.replace(re, (m) => {
|
|
273
|
+
if (/^\[REDACTED/.test(m))
|
|
274
|
+
return m;
|
|
275
|
+
if (kind !== ".env secret")
|
|
276
|
+
count++;
|
|
277
|
+
if (kind === ".env secret") {
|
|
278
|
+
if (/[=:][ \t]*["']?\[REDACTED/.test(m))
|
|
279
|
+
return m; // its value was already a known key
|
|
280
|
+
count++;
|
|
281
|
+
const eq = /^([^=:]*[=:][ \t]*)/.exec(m);
|
|
282
|
+
return `${eq ? eq[1] : ""}[REDACTED:secret]`;
|
|
283
|
+
}
|
|
284
|
+
return `[REDACTED:${kind}]`;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return { text: out, count };
|
|
288
|
+
}
|
|
289
|
+
// ---- dropped paths -----------------------------------------------------------------
|
|
290
|
+
/**
|
|
291
|
+
* Split a line the way a terminal pastes a dragged file: 'quoted paths',
|
|
292
|
+
* "double quoted", escaped\ spaces, file:// URLs, Windows C:\paths. A
|
|
293
|
+
* backslash only escapes shell-special characters, so C:\Users stays intact.
|
|
294
|
+
*/
|
|
295
|
+
export function splitPathTokens(line) {
|
|
296
|
+
const out = [];
|
|
297
|
+
let cur = "";
|
|
298
|
+
let quote = null;
|
|
299
|
+
let had = false;
|
|
300
|
+
const s = line.trim();
|
|
301
|
+
for (let i = 0; i < s.length; i++) {
|
|
302
|
+
const c = s[i];
|
|
303
|
+
if (quote) {
|
|
304
|
+
if (c === quote)
|
|
305
|
+
quote = null;
|
|
306
|
+
else
|
|
307
|
+
cur += c;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (c === "'" || c === '"') {
|
|
311
|
+
quote = c;
|
|
312
|
+
had = true;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
// A backslash never escapes a backslash: that is a UNC path or a Windows separator.
|
|
316
|
+
if (c === "\\" && i + 1 < s.length && /[\s'"()\[\]{}&;!$#*?<>|~`,]/.test(s[i + 1])) {
|
|
317
|
+
cur += s[++i];
|
|
318
|
+
had = true;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (/\s/.test(c)) {
|
|
322
|
+
if (cur || had)
|
|
323
|
+
out.push(cur);
|
|
324
|
+
cur = "";
|
|
325
|
+
had = false;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
cur += c;
|
|
329
|
+
had = true;
|
|
330
|
+
}
|
|
331
|
+
if (cur || had)
|
|
332
|
+
out.push(cur);
|
|
333
|
+
return out.map(normalizePathToken).filter(Boolean);
|
|
334
|
+
}
|
|
335
|
+
/** One path as a person typed or dropped it: file:// URLs and ~ resolved. */
|
|
336
|
+
export function normalizePathToken(t) {
|
|
337
|
+
if (/^file:\/\//i.test(t)) {
|
|
338
|
+
try {
|
|
339
|
+
// file:///C:/x → C:/x on Windows; file:///Users/x → /Users/x
|
|
340
|
+
let p = decodeURIComponent(new URL(t).pathname);
|
|
341
|
+
if (/^\/[A-Za-z]:\//.test(p))
|
|
342
|
+
p = p.slice(1);
|
|
343
|
+
return p;
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return t;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (t === "~" || t.startsWith("~/"))
|
|
350
|
+
return path.join(os.homedir(), t.slice(1));
|
|
351
|
+
return t;
|
|
352
|
+
}
|
|
353
|
+
/** A path a drag-and-drop would paste: absolute (/x, ~/x, C:\x, \\server\x) or file://. */
|
|
354
|
+
export function isAbsoluteish(p) {
|
|
355
|
+
return path.isAbsolute(p) || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith("\\\\");
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* If the whole line is just one or more existing absolute paths (a dragged
|
|
359
|
+
* file, or a few), return them. Anything else — a goal that mentions a path,
|
|
360
|
+
* a missing file — is not a drop.
|
|
361
|
+
*/
|
|
362
|
+
export function droppedPaths(line, exists = fs.existsSync) {
|
|
363
|
+
if (!line.trim() || line.length > 8000 || /\n/.test(line.trim()))
|
|
364
|
+
return null;
|
|
365
|
+
const toks = splitPathTokens(line);
|
|
366
|
+
if (!toks.length || !toks.every(isAbsoluteish))
|
|
367
|
+
return null;
|
|
368
|
+
return toks.every((t) => exists(t)) ? toks : null;
|
|
369
|
+
}
|
|
370
|
+
// ---- folders & globs -------------------------------------------------------------
|
|
371
|
+
const SKIP_DIRS = new Set([".git", "node_modules", ".huddle", "dist", "build", "out", ".next", ".nuxt", ".venv", "venv", "env", "__pycache__", "target", "coverage", ".cache", ".idea", ".vscode", ".turbo", "vendor", ".gradle", "Pods"]);
|
|
372
|
+
const SKIP_FILES = /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|poetry\.lock|\.DS_Store)$|\.(min\.js|map|lock)$/i;
|
|
373
|
+
/** A tiny .gitignore reader: names, dir/, *.ext, /rooted — enough to skip the usual noise. */
|
|
374
|
+
function ignoreRules(dir) {
|
|
375
|
+
let txt = "";
|
|
376
|
+
try {
|
|
377
|
+
txt = fs.readFileSync(path.join(dir, ".gitignore"), "utf8");
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return [];
|
|
381
|
+
}
|
|
382
|
+
return txt
|
|
383
|
+
.split(/\r?\n/)
|
|
384
|
+
.map((l) => l.trim())
|
|
385
|
+
.filter((l) => l && !l.startsWith("#") && !l.startsWith("!"))
|
|
386
|
+
.map((pat) => {
|
|
387
|
+
const dirOnly = pat.endsWith("/");
|
|
388
|
+
const p = pat.replace(/\/$/, "").replace(/^\//, "");
|
|
389
|
+
const re = globRe(p.includes("/") ? p : `**/${p}`);
|
|
390
|
+
return (rel, isDir) => (!dirOnly || isDir) && re.test(rel);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
/** Glob → RegExp over forward-slash relative paths: *, **, ?, [abc], {a,b}. */
|
|
394
|
+
export function globRe(glob) {
|
|
395
|
+
let re = "";
|
|
396
|
+
for (let i = 0; i < glob.length; i++) {
|
|
397
|
+
const c = glob[i];
|
|
398
|
+
if (c === "*") {
|
|
399
|
+
if (glob[i + 1] === "*") {
|
|
400
|
+
i++;
|
|
401
|
+
if (glob[i + 1] === "/") {
|
|
402
|
+
i++;
|
|
403
|
+
re += "(?:.*/)?";
|
|
404
|
+
}
|
|
405
|
+
else
|
|
406
|
+
re += ".*";
|
|
407
|
+
}
|
|
408
|
+
else
|
|
409
|
+
re += "[^/]*";
|
|
410
|
+
}
|
|
411
|
+
else if (c === "?")
|
|
412
|
+
re += "[^/]";
|
|
413
|
+
else if (c === "[") {
|
|
414
|
+
const j = glob.indexOf("]", i);
|
|
415
|
+
if (j < 0)
|
|
416
|
+
re += "\\[";
|
|
417
|
+
else {
|
|
418
|
+
re += glob.slice(i, j + 1);
|
|
419
|
+
i = j;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
else if (c === "{") {
|
|
423
|
+
const j = glob.indexOf("}", i);
|
|
424
|
+
if (j < 0)
|
|
425
|
+
re += "\\{";
|
|
426
|
+
else {
|
|
427
|
+
re += "(?:" + glob.slice(i + 1, j).split(",").map((x) => x.replace(/[.+^$()|\\]/g, "\\$&").replace(/\*/g, "[^/]*")).join("|") + ")";
|
|
428
|
+
i = j;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
else
|
|
432
|
+
re += c.replace(/[.+^$()|\\]/g, "\\$&");
|
|
433
|
+
}
|
|
434
|
+
return new RegExp(`^${re}$`);
|
|
435
|
+
}
|
|
436
|
+
export const isGlob = (p) => /[*?[{]/.test(p);
|
|
437
|
+
/**
|
|
438
|
+
* Every file under `dir` (relative, forward slashes), skipping the usual
|
|
439
|
+
* build/vendor dirs and .gitignore'd paths. Never follows a symlinked folder
|
|
440
|
+
* (no loops); stops at `max`.
|
|
441
|
+
*/
|
|
442
|
+
export function walk(dir, max = CAPS.folderFiles * 4) {
|
|
443
|
+
const files = [];
|
|
444
|
+
let skipped = 0;
|
|
445
|
+
const rules = ignoreRules(dir);
|
|
446
|
+
const seen = new Set();
|
|
447
|
+
const visit = (abs, rel) => {
|
|
448
|
+
let real;
|
|
449
|
+
try {
|
|
450
|
+
real = fs.realpathSync(abs);
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (seen.has(real))
|
|
456
|
+
return;
|
|
457
|
+
seen.add(real);
|
|
458
|
+
let ents;
|
|
459
|
+
try {
|
|
460
|
+
ents = fs.readdirSync(abs, { withFileTypes: true });
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
skipped++;
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
ents.sort((a, b) => a.name.localeCompare(b.name));
|
|
467
|
+
for (const e of ents) {
|
|
468
|
+
if (files.length >= max)
|
|
469
|
+
return;
|
|
470
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
471
|
+
if (e.isSymbolicLink()) {
|
|
472
|
+
// A symlinked file is fine; a symlinked folder could loop.
|
|
473
|
+
try {
|
|
474
|
+
if (!fs.statSync(path.join(abs, e.name)).isFile())
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
else if (e.isDirectory()) {
|
|
482
|
+
if (SKIP_DIRS.has(e.name) || rules.some((f) => f(r, true)))
|
|
483
|
+
continue;
|
|
484
|
+
visit(path.join(abs, e.name), r);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (SKIP_FILES.test(r) || rules.some((f) => f(r, false)))
|
|
488
|
+
continue;
|
|
489
|
+
files.push(r);
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
visit(dir, "");
|
|
493
|
+
return { files, skipped };
|
|
494
|
+
}
|
|
495
|
+
/** Expand a glob (relative to cwd, or absolute) to the files it matches. */
|
|
496
|
+
export function expandGlob(pattern, cwd = process.cwd()) {
|
|
497
|
+
const norm = pattern.replace(/\\/g, "/");
|
|
498
|
+
const parts = norm.split("/");
|
|
499
|
+
const firstGlob = parts.findIndex(isGlob);
|
|
500
|
+
const baseRel = parts.slice(0, firstGlob).join("/");
|
|
501
|
+
const base = path.resolve(cwd, baseRel || ".");
|
|
502
|
+
const rest = parts.slice(firstGlob).join("/");
|
|
503
|
+
const re = globRe(rest);
|
|
504
|
+
if (!fs.existsSync(base))
|
|
505
|
+
return [];
|
|
506
|
+
return walk(base).files.filter((f) => re.test(f)).map((f) => path.join(base, f));
|
|
507
|
+
}
|
|
508
|
+
const stores = new WeakMap();
|
|
509
|
+
export class ContextStore {
|
|
510
|
+
db;
|
|
511
|
+
dir;
|
|
512
|
+
constructor(db) {
|
|
513
|
+
this.db = db;
|
|
514
|
+
// Next to the Room db: <repo>/.huddle/huddle.db → <repo>/.huddle/context/.
|
|
515
|
+
// Attach mode's live room (.huddle/live.db) is a separate db with its own
|
|
516
|
+
// ctx-N numbering, so its files go in .huddle/live-context/ — sharing one
|
|
517
|
+
// folder would let the two stores overwrite each other's ctx-1.
|
|
518
|
+
this.dir =
|
|
519
|
+
db.path === ":memory:"
|
|
520
|
+
? fs.mkdtempSync(path.join(os.tmpdir(), "huddle-ctx-"))
|
|
521
|
+
: path.join(path.dirname(db.path), path.basename(db.path) === "live.db" ? "live-context" : "context");
|
|
522
|
+
db.sql.exec(SCHEMA);
|
|
523
|
+
}
|
|
524
|
+
/** The store for a Room (one per db, made on first use). */
|
|
525
|
+
static open(db) {
|
|
526
|
+
let s = stores.get(db);
|
|
527
|
+
if (!s) {
|
|
528
|
+
s = new ContextStore(db);
|
|
529
|
+
stores.set(db, s);
|
|
530
|
+
}
|
|
531
|
+
return s;
|
|
532
|
+
}
|
|
533
|
+
// ---- reading -------------------------------------------------------------
|
|
534
|
+
rows(sql, ...params) {
|
|
535
|
+
const rows = this.db.sql.prepare(sql).all(...params);
|
|
536
|
+
if (!rows.length)
|
|
537
|
+
return [];
|
|
538
|
+
const notes = this.db.sql.prepare("SELECT * FROM context_note ORDER BY created_at, rowid").all();
|
|
539
|
+
return rows.map((r) => {
|
|
540
|
+
const { seq: _s, removed_at: _r, sections, ...rest } = r;
|
|
541
|
+
return { ...rest, sections: JSON.parse(sections || "[]"), notes: notes.filter((n) => n.item_id === r.id) };
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
list() {
|
|
545
|
+
return this.rows("SELECT * FROM context_item WHERE removed_at IS NULL ORDER BY seq");
|
|
546
|
+
}
|
|
547
|
+
get(id) {
|
|
548
|
+
return this.rows("SELECT * FROM context_item WHERE id=? AND removed_at IS NULL", normId(id))[0];
|
|
549
|
+
}
|
|
550
|
+
/** Absolute path of an item's file on this machine. */
|
|
551
|
+
pathOf(item) {
|
|
552
|
+
return path.join(this.dir, item.file);
|
|
553
|
+
}
|
|
554
|
+
totalBytes() {
|
|
555
|
+
return this.db.sql.prepare("SELECT COALESCE(SUM(bytes),0) AS n FROM context_item WHERE removed_at IS NULL").get().n;
|
|
556
|
+
}
|
|
557
|
+
// ---- preparing (nothing written yet: the caller may ask about secrets) ---
|
|
558
|
+
/** A paste (or stdin) as an item. */
|
|
559
|
+
prepareText(raw, opts = {}) {
|
|
560
|
+
// CRLF → LF; stray control chars from a terminal (not tabs/newlines) dropped.
|
|
561
|
+
const text = raw.replace(/\r\n?/g, "\n").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").replace(/\n+$/, "");
|
|
562
|
+
if (!text.trim())
|
|
563
|
+
return { ok: false, reason: "that paste is empty" };
|
|
564
|
+
const data = Buffer.from(text, "utf8");
|
|
565
|
+
if (data.length > CAPS.textBytes)
|
|
566
|
+
return { ok: false, reason: `that paste is ${human(data.length)} — over the ${human(CAPS.textBytes)} text cap. Save it to a file and add a part of it, or raise HUDDLE_CTX_MAX_TEXT_MB` };
|
|
567
|
+
const lang = guessLang(text);
|
|
568
|
+
const first = text.split("\n").find((l) => l.trim()) ?? "";
|
|
569
|
+
return {
|
|
570
|
+
kind: lang === "code" || lang === "stack trace" || lang === "diff" ? "code" : "text",
|
|
571
|
+
title: (opts.title ?? first.trim()).replace(/\s+/g, " ").slice(0, 60) + (!opts.title && first.trim().length > 60 ? "…" : ""),
|
|
572
|
+
ext: lang === "markdown" ? "md" : lang === "json" ? "json" : lang === "diff" ? "diff" : "txt",
|
|
573
|
+
mime: "text/plain",
|
|
574
|
+
data,
|
|
575
|
+
text,
|
|
576
|
+
lang,
|
|
577
|
+
source: opts.source ?? "paste",
|
|
578
|
+
bundle: false,
|
|
579
|
+
secrets: scanSecrets(text),
|
|
580
|
+
warnings: [],
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
/** A file or a folder (its text files, as one bundle item). */
|
|
584
|
+
preparePath(p, cwd = process.cwd()) {
|
|
585
|
+
const abs = path.resolve(cwd, p);
|
|
586
|
+
let st;
|
|
587
|
+
try {
|
|
588
|
+
st = fs.statSync(abs);
|
|
589
|
+
}
|
|
590
|
+
catch (e) {
|
|
591
|
+
if (e?.code === "EACCES" || e?.code === "EPERM")
|
|
592
|
+
return { ok: false, reason: `can't read ${p} — permission denied` };
|
|
593
|
+
return { ok: false, reason: `no such file: ${p}` };
|
|
594
|
+
}
|
|
595
|
+
if (st.isDirectory())
|
|
596
|
+
return this.prepareBundle(path.basename(abs) + "/", abs, walk(abs).files.map((f) => path.join(abs, f)));
|
|
597
|
+
if (!st.isFile())
|
|
598
|
+
return { ok: false, reason: `${p} isn't a regular file` };
|
|
599
|
+
const name = path.basename(abs);
|
|
600
|
+
const ext = extOf(name);
|
|
601
|
+
const isImage = ext in IMAGE_EXT;
|
|
602
|
+
const isPdf = ext === "pdf";
|
|
603
|
+
const knownText = !isImage && !isPdf && (TEXT_EXT.has(ext) || ext in CODE_LANG);
|
|
604
|
+
// Text is capped tighter than files: it is what agents actually read.
|
|
605
|
+
const cap = knownText ? CAPS.textBytes : CAPS.fileBytes;
|
|
606
|
+
if (st.size > cap)
|
|
607
|
+
return { ok: false, reason: `${name} is ${human(st.size)} — over the ${human(cap)} ${knownText ? "text" : "file"} cap (${knownText ? "HUDDLE_CTX_MAX_TEXT_MB" : "HUDDLE_CTX_MAX_FILE_MB"} raises it)` };
|
|
608
|
+
let data;
|
|
609
|
+
try {
|
|
610
|
+
data = fs.readFileSync(abs);
|
|
611
|
+
}
|
|
612
|
+
catch (e) {
|
|
613
|
+
return { ok: false, reason: e?.code === "EACCES" || e?.code === "EPERM" ? `can't read ${name} — permission denied` : `couldn't read ${name}: ${e?.message ?? e}` };
|
|
614
|
+
}
|
|
615
|
+
if (isImage) {
|
|
616
|
+
const mime = IMAGE_EXT[ext];
|
|
617
|
+
return { kind: "image", title: name, ext, mime, data, text: null, lang: ext, source: name, bundle: false, secrets: [], warnings: [] };
|
|
618
|
+
}
|
|
619
|
+
if (isPdf)
|
|
620
|
+
return { kind: "pdf", title: name, ext, mime: "application/pdf", data, text: null, lang: "pdf", source: name, bundle: false, secrets: [], warnings: [] };
|
|
621
|
+
if (knownText || looksText(data)) {
|
|
622
|
+
if (data.length > CAPS.textBytes)
|
|
623
|
+
return { ok: false, reason: `${name} is ${human(data.length)} of text — over the ${human(CAPS.textBytes)} text cap (HUDDLE_CTX_MAX_TEXT_MB raises it)` };
|
|
624
|
+
const text = data.toString("utf8").replace(/\r\n?/g, "\n");
|
|
625
|
+
const lang = CODE_LANG[ext] ?? TEXT_LANG[ext] ?? guessLang(text);
|
|
626
|
+
return {
|
|
627
|
+
kind: ext in CODE_LANG ? "code" : "text",
|
|
628
|
+
title: name,
|
|
629
|
+
ext: ext || "txt",
|
|
630
|
+
mime: "text/plain",
|
|
631
|
+
data: Buffer.from(text, "utf8"),
|
|
632
|
+
text,
|
|
633
|
+
lang,
|
|
634
|
+
source: name,
|
|
635
|
+
bundle: false,
|
|
636
|
+
secrets: scanSecrets(text),
|
|
637
|
+
warnings: [],
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
return { kind: "file", title: name, ext: ext || "bin", mime: "application/octet-stream", data, text: null, lang: ext || "binary", source: name, bundle: false, secrets: [], warnings: [`${name} is binary — agents get its path, not its contents`] };
|
|
641
|
+
}
|
|
642
|
+
/** Several files in one item: "===== path =====" headers, one section per file. */
|
|
643
|
+
prepareBundle(title, base, files) {
|
|
644
|
+
const parts = [];
|
|
645
|
+
const skipped = [];
|
|
646
|
+
let bytes = 0;
|
|
647
|
+
for (const f of files) {
|
|
648
|
+
if (parts.length >= CAPS.folderFiles) {
|
|
649
|
+
skipped.push(`${files.length - CAPS.folderFiles} more file(s) past the ${CAPS.folderFiles}-file cap`);
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
const rel = path.relative(base, f).replace(/\\/g, "/") || path.basename(f);
|
|
653
|
+
let data;
|
|
654
|
+
try {
|
|
655
|
+
if (fs.statSync(f).size > CAPS.textBytes) {
|
|
656
|
+
skipped.push(`${rel} (too big)`);
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
data = fs.readFileSync(f);
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
skipped.push(`${rel} (unreadable)`);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const ext = extOf(f);
|
|
666
|
+
if (ext in IMAGE_EXT || ext === "pdf" || !looksText(data)) {
|
|
667
|
+
skipped.push(`${rel} (binary — add it on its own)`);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (bytes + data.length > CAPS.textBytes) {
|
|
671
|
+
skipped.push(`${rel} and the rest (the bundle hit the ${human(CAPS.textBytes)} text cap)`);
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
bytes += data.length;
|
|
675
|
+
parts.push(`===== ${rel} =====\n${data.toString("utf8").replace(/\r\n?/g, "\n").replace(/\n+$/, "")}`);
|
|
676
|
+
}
|
|
677
|
+
if (!parts.length)
|
|
678
|
+
return { ok: false, reason: `no text files in ${title}${skipped.length ? ` (skipped: ${skipped.slice(0, 3).join(", ")})` : ""}` };
|
|
679
|
+
const text = parts.join("\n");
|
|
680
|
+
return {
|
|
681
|
+
kind: "code",
|
|
682
|
+
title: title.slice(0, 60),
|
|
683
|
+
ext: "txt",
|
|
684
|
+
mime: "text/plain",
|
|
685
|
+
data: Buffer.from(text, "utf8"),
|
|
686
|
+
text,
|
|
687
|
+
lang: `${parts.length} files`,
|
|
688
|
+
source: title,
|
|
689
|
+
bundle: true,
|
|
690
|
+
secrets: scanSecrets(text),
|
|
691
|
+
warnings: skipped.length ? [`skipped ${skipped.length}: ${skipped.slice(0, 4).join(", ")}${skipped.length > 4 ? ", …" : ""}`] : [],
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
// ---- writing --------------------------------------------------------------
|
|
695
|
+
/** Save a prepared item. `redact` (default true) rewrites likely secrets first. */
|
|
696
|
+
commit(p, opts = {}) {
|
|
697
|
+
let data = p.data;
|
|
698
|
+
let text = p.text;
|
|
699
|
+
let redacted = 0;
|
|
700
|
+
if (text !== null && p.secrets.length && opts.redact !== false) {
|
|
701
|
+
const r = redactSecrets(text);
|
|
702
|
+
text = r.text;
|
|
703
|
+
redacted = r.count;
|
|
704
|
+
data = Buffer.from(text, "utf8");
|
|
705
|
+
}
|
|
706
|
+
const sha = crypto.createHash("sha256").update(data).digest("hex");
|
|
707
|
+
const dup = this.rows("SELECT * FROM context_item WHERE sha256=? AND removed_at IS NULL", sha)[0];
|
|
708
|
+
if (dup)
|
|
709
|
+
return { ok: true, item: dup, duplicate: true };
|
|
710
|
+
const live = this.list();
|
|
711
|
+
if (live.length >= CAPS.items)
|
|
712
|
+
return { ok: false, reason: `the screen already has ${live.length} items — ctx rm some first` };
|
|
713
|
+
const total = this.totalBytes();
|
|
714
|
+
if (total + data.length > CAPS.totalBytes)
|
|
715
|
+
return { ok: false, reason: `the screen is full (${human(total)} of ${human(CAPS.totalBytes)}) — ctx rm some items, or raise HUDDLE_CTX_MAX_TOTAL_MB` };
|
|
716
|
+
const seq = (this.db.sql.prepare("SELECT MAX(seq) AS n FROM context_item").get().n ?? 0) + 1;
|
|
717
|
+
const id = `ctx-${seq}`;
|
|
718
|
+
const file = `${id}.${p.ext.replace(/[^\w]/g, "") || "bin"}`;
|
|
719
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
720
|
+
fs.writeFileSync(path.join(this.dir, file), data);
|
|
721
|
+
const lines = text === null ? 0 : text.split("\n").length;
|
|
722
|
+
let extra = "";
|
|
723
|
+
if (p.kind === "image") {
|
|
724
|
+
const sz = imageSize(data, p.mime);
|
|
725
|
+
extra = sz ? `${sz.w}×${sz.h}` : "";
|
|
726
|
+
}
|
|
727
|
+
else if (p.kind === "pdf") {
|
|
728
|
+
const pages = (data.toString("latin1").match(/\/Type\s*\/Page(?!s)/g) ?? []).length;
|
|
729
|
+
extra = pages ? `${pages} page${pages > 1 ? "s" : ""}` : "";
|
|
730
|
+
}
|
|
731
|
+
const item = {
|
|
732
|
+
id,
|
|
733
|
+
kind: p.kind,
|
|
734
|
+
title: p.title || id,
|
|
735
|
+
file,
|
|
736
|
+
mime: p.mime,
|
|
737
|
+
bytes: data.length,
|
|
738
|
+
lines,
|
|
739
|
+
sha256: sha,
|
|
740
|
+
added_by: opts.by ?? "you",
|
|
741
|
+
created_at: Date.now(),
|
|
742
|
+
summary: summarize(p.kind, text, p.lang, extra),
|
|
743
|
+
brain_summary: "",
|
|
744
|
+
sections: text === null ? [] : sectionsOf(text, p.bundle),
|
|
745
|
+
source: p.source,
|
|
746
|
+
notes: [],
|
|
747
|
+
};
|
|
748
|
+
this.db.sql
|
|
749
|
+
.prepare(`INSERT INTO context_item (id,seq,kind,title,file,mime,bytes,lines,sha256,added_by,created_at,summary,brain_summary,sections,source)
|
|
750
|
+
VALUES (@id,@seq,@kind,@title,@file,@mime,@bytes,@lines,@sha256,@added_by,@created_at,@summary,@brain_summary,@sections,@source)`)
|
|
751
|
+
.run({
|
|
752
|
+
id,
|
|
753
|
+
seq,
|
|
754
|
+
kind: item.kind,
|
|
755
|
+
title: item.title,
|
|
756
|
+
file,
|
|
757
|
+
mime: item.mime,
|
|
758
|
+
bytes: item.bytes,
|
|
759
|
+
lines,
|
|
760
|
+
sha256: sha,
|
|
761
|
+
added_by: item.added_by,
|
|
762
|
+
created_at: item.created_at,
|
|
763
|
+
summary: item.summary,
|
|
764
|
+
brain_summary: "",
|
|
765
|
+
sections: JSON.stringify(item.sections),
|
|
766
|
+
source: item.source,
|
|
767
|
+
});
|
|
768
|
+
this.writeMirror();
|
|
769
|
+
this.db.publish("context.added", { agent_id: null, payload: { id, kind: item.kind, title: item.title, bytes: item.bytes, lines, by: item.added_by, summary: item.summary, redacted } });
|
|
770
|
+
const announced = opts.announce === false ? [] : this.announce(item);
|
|
771
|
+
return { ok: true, item, ...(redacted ? { redacted } : {}), ...(p.warnings.length ? { warnings: p.warnings } : {}), ...(announced.length ? { announced } : {}) };
|
|
772
|
+
}
|
|
773
|
+
addText(text, opts = {}) {
|
|
774
|
+
const p = this.prepareText(text, opts);
|
|
775
|
+
return "ok" in p ? p : this.commit(p, opts);
|
|
776
|
+
}
|
|
777
|
+
addPath(p, opts = {}) {
|
|
778
|
+
const prep = this.preparePath(p, opts.cwd);
|
|
779
|
+
return "ok" in prep ? prep : this.commit(prep, opts);
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Mid-run: tell every agent working right now, through its inbox (the same
|
|
783
|
+
* path huddle_send uses), so nobody finishes without seeing the new item.
|
|
784
|
+
*/
|
|
785
|
+
announce(item) {
|
|
786
|
+
const working = this.db.listAgents().filter((a) => a.status === "working").map((a) => a.id);
|
|
787
|
+
const what = item.kind === "image" ? "screenshot" : item.kind;
|
|
788
|
+
for (const to of working)
|
|
789
|
+
this.db.sendMessage({
|
|
790
|
+
from: item.added_by,
|
|
791
|
+
to,
|
|
792
|
+
kind: "note",
|
|
793
|
+
body: `📎 new on the screen from ${item.added_by}: ${item.id} ${what} '${item.title}' — huddle_context_read ${item.id}`,
|
|
794
|
+
});
|
|
795
|
+
return working;
|
|
796
|
+
}
|
|
797
|
+
remove(id, by = "you") {
|
|
798
|
+
const it = this.get(id);
|
|
799
|
+
if (!it)
|
|
800
|
+
return false;
|
|
801
|
+
this.db.sql.prepare("UPDATE context_item SET removed_at=? WHERE id=?").run(Date.now(), it.id);
|
|
802
|
+
this.db.sql.prepare("DELETE FROM context_link WHERE item_id=?").run(it.id);
|
|
803
|
+
try {
|
|
804
|
+
fs.rmSync(this.pathOf(it), { force: true });
|
|
805
|
+
fs.rmSync(this.pathOf(it) + ".txt", { force: true });
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
/* already gone */
|
|
809
|
+
}
|
|
810
|
+
this.writeMirror();
|
|
811
|
+
this.db.publish("context.removed", { payload: { id: it.id, title: it.title, by } });
|
|
812
|
+
return true;
|
|
813
|
+
}
|
|
814
|
+
/** A comment pinned on an item — every agent and person sees it in the index. */
|
|
815
|
+
note(id, body, by) {
|
|
816
|
+
const it = this.get(id);
|
|
817
|
+
if (!it)
|
|
818
|
+
return { error: `no item ${id} on the screen — huddle_context lists them` };
|
|
819
|
+
const text = String(body ?? "").replace(/\s+/g, " ").trim().slice(0, 1000);
|
|
820
|
+
if (!text)
|
|
821
|
+
return { error: "empty note" };
|
|
822
|
+
const n = { id: crypto.randomBytes(4).toString("hex"), item_id: it.id, by, body: text, created_at: Date.now() };
|
|
823
|
+
this.db.sql.prepare("INSERT INTO context_note (id,item_id,by,body,created_at) VALUES (@id,@item_id,@by,@body,@created_at)").run(n);
|
|
824
|
+
this.writeMirror();
|
|
825
|
+
this.db.publish("context.note", { agent_id: by, payload: { id: it.id, title: it.title, by, body: text } });
|
|
826
|
+
return n;
|
|
827
|
+
}
|
|
828
|
+
setBrainSummary(id, s) {
|
|
829
|
+
this.db.sql.prepare("UPDATE context_item SET brain_summary=? WHERE id=?").run(s.replace(/\s+/g, " ").trim().slice(0, 200), normId(id));
|
|
830
|
+
this.writeMirror();
|
|
831
|
+
}
|
|
832
|
+
/** The planner tied these items to a task: they ride in that task's prompt. */
|
|
833
|
+
link(taskId, ids) {
|
|
834
|
+
const ok = ids.map(normId).filter((id) => this.get(id));
|
|
835
|
+
for (const id of ok)
|
|
836
|
+
this.db.sql.prepare("INSERT OR IGNORE INTO context_link (task_id,item_id) VALUES (?,?)").run(taskId, id);
|
|
837
|
+
if (ok.length)
|
|
838
|
+
this.writeMirror();
|
|
839
|
+
return ok;
|
|
840
|
+
}
|
|
841
|
+
linked(taskId) {
|
|
842
|
+
const ids = this.db.sql.prepare("SELECT item_id FROM context_link WHERE task_id=?").all(taskId).map((r) => r.item_id);
|
|
843
|
+
return this.list().filter((i) => ids.includes(i.id));
|
|
844
|
+
}
|
|
845
|
+
/** index.json: the same rows as plain JSON next to the files. */
|
|
846
|
+
writeMirror() {
|
|
847
|
+
const links = {};
|
|
848
|
+
for (const r of this.db.sql.prepare("SELECT task_id, item_id FROM context_link").all())
|
|
849
|
+
(links[r.task_id] ??= []).push(r.item_id);
|
|
850
|
+
try {
|
|
851
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
852
|
+
const tmp = path.join(this.dir, `index.json.${process.pid}.tmp`);
|
|
853
|
+
fs.writeFileSync(tmp, JSON.stringify({ items: this.list(), links }, null, 1));
|
|
854
|
+
fs.renameSync(tmp, path.join(this.dir, "index.json"));
|
|
855
|
+
}
|
|
856
|
+
catch {
|
|
857
|
+
/* the db is the truth; a missing mirror only means a shorter prompt */
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
// ---- reading content -------------------------------------------------------
|
|
861
|
+
/** An item's text: the file itself, or a PDF's extracted text (pdftotext, if installed). */
|
|
862
|
+
textOf(item) {
|
|
863
|
+
if (item.kind === "text" || item.kind === "code") {
|
|
864
|
+
try {
|
|
865
|
+
return fs.readFileSync(this.pathOf(item), "utf8");
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (item.kind === "pdf")
|
|
872
|
+
return pdfText(this.pathOf(item));
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
export const normId = (id) => {
|
|
877
|
+
const s = String(id ?? "").trim().toLowerCase();
|
|
878
|
+
const m = /^(?:ctx[-_ ]?)?(\d+)$/.exec(s);
|
|
879
|
+
return m ? `ctx-${m[1]}` : s;
|
|
880
|
+
};
|
|
881
|
+
/** A PDF's text via poppler's pdftotext, cached next to it. null if unavailable. */
|
|
882
|
+
export function pdfText(file) {
|
|
883
|
+
const cache = file + ".txt";
|
|
884
|
+
try {
|
|
885
|
+
if (fs.existsSync(cache))
|
|
886
|
+
return fs.readFileSync(cache, "utf8");
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
/* re-extract */
|
|
890
|
+
}
|
|
891
|
+
const r = spawnToolSync(["pdftotext", "-layout", file, cache], { timeout: 20_000, stdio: "ignore" });
|
|
892
|
+
if (r.status !== 0)
|
|
893
|
+
return null;
|
|
894
|
+
try {
|
|
895
|
+
return fs.readFileSync(cache, "utf8");
|
|
896
|
+
}
|
|
897
|
+
catch {
|
|
898
|
+
return null;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
// ---- the index (what prompts carry instead of content) --------------------------
|
|
902
|
+
const kindWord = (i) => (i.kind === "image" ? "image" : i.kind);
|
|
903
|
+
const sizeOf = (i) => (i.lines && (i.kind === "text" || i.kind === "code") ? `${i.lines} line${i.lines === 1 ? "" : "s"}, ${human(i.bytes)}` : human(i.bytes));
|
|
904
|
+
/** One line per item: id · kind · title · size · summary (+ the latest note). */
|
|
905
|
+
export function indexLine(i, max = 220) {
|
|
906
|
+
const sum = i.brain_summary || i.summary;
|
|
907
|
+
const note = i.notes.at(-1);
|
|
908
|
+
let s = `- ${i.id} · ${kindWord(i)} · "${i.title}" · ${sizeOf(i)}${sum ? " · " + sum : ""}`;
|
|
909
|
+
if (note)
|
|
910
|
+
s += ` · 📌 ${note.by}: ${note.body}`;
|
|
911
|
+
if (i.notes.length > 1)
|
|
912
|
+
s += ` (+${i.notes.length - 1} note${i.notes.length > 2 ? "s" : ""})`;
|
|
913
|
+
return s.length > max ? s.slice(0, max - 1) + "…" : s;
|
|
914
|
+
}
|
|
915
|
+
/** The whole index, newest first if it must be cut, never over `cap` chars. */
|
|
916
|
+
export function screenIndex(items, cap = INDEX_CAP) {
|
|
917
|
+
if (!items.length)
|
|
918
|
+
return "";
|
|
919
|
+
const lines = [];
|
|
920
|
+
let used = 0;
|
|
921
|
+
const newestFirst = [...items].reverse();
|
|
922
|
+
let shown = 0;
|
|
923
|
+
for (const it of newestFirst) {
|
|
924
|
+
const l = indexLine(it);
|
|
925
|
+
if (used + l.length + 1 > cap - 60)
|
|
926
|
+
break;
|
|
927
|
+
lines.push(l);
|
|
928
|
+
used += l.length + 1;
|
|
929
|
+
shown++;
|
|
930
|
+
}
|
|
931
|
+
lines.reverse();
|
|
932
|
+
if (shown < items.length)
|
|
933
|
+
lines.unshift(`- …${items.length - shown} older item(s) — huddle_context lists them all`);
|
|
934
|
+
return lines.join("\n");
|
|
935
|
+
}
|
|
936
|
+
const READ_HINT = `Open one with huddle_context_read(id) (≤${READ_MAX_LINES} lines per call; from_line/to_line or section for a part; images & PDFs come back as a file path to open), find text with huddle_context_search(query), and pin what you learn on an item with huddle_context_note(id, note) — everyone sees notes.`;
|
|
937
|
+
/** The index as a mirror reader sees it (launcher, planner): .huddle/context/index.json. */
|
|
938
|
+
export function readMirror(repoRoot) {
|
|
939
|
+
const dir = path.join(repoRoot, ".huddle", "context");
|
|
940
|
+
try {
|
|
941
|
+
const j = JSON.parse(fs.readFileSync(path.join(dir, "index.json"), "utf8"));
|
|
942
|
+
return { items: Array.isArray(j.items) ? j.items : [], links: j.links ?? {}, dir };
|
|
943
|
+
}
|
|
944
|
+
catch {
|
|
945
|
+
return { items: [], links: {}, dir };
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
function inlineText(dir, it) {
|
|
949
|
+
if ((it.kind !== "text" && it.kind !== "code") || it.bytes > INLINE_MAX)
|
|
950
|
+
return null;
|
|
951
|
+
try {
|
|
952
|
+
return fs.readFileSync(path.join(dir, it.file), "utf8");
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* The SHARED SCREEN section of the planner's / council's repo snapshot: the
|
|
960
|
+
* index, plus small text items inline (one prompt, so a bigger budget than a
|
|
961
|
+
* task's), and how to tie items to tasks in the plan JSON.
|
|
962
|
+
*/
|
|
963
|
+
export function screenForPlanner(repoRoot, inlineBudget = 6_000) {
|
|
964
|
+
const { items, dir } = readMirror(repoRoot);
|
|
965
|
+
if (!items.length)
|
|
966
|
+
return "";
|
|
967
|
+
const out = [
|
|
968
|
+
`SHARED SCREEN — what the user put up for the whole team (pastes, files, screenshots). Workers don't get copies; they open items with huddle_context_read. The newest item is usually what the goal is about.`,
|
|
969
|
+
screenIndex(items),
|
|
970
|
+
`When a task needs an item, add "context": ["ctx-N"] to that task in your JSON (Huddle hands those items to that task's agent), and write the requirements the task needs from it into its spec.`,
|
|
971
|
+
];
|
|
972
|
+
let budget = inlineBudget;
|
|
973
|
+
for (const it of [...items].reverse()) {
|
|
974
|
+
if (it.kind !== "text" && it.kind !== "code")
|
|
975
|
+
continue;
|
|
976
|
+
let body;
|
|
977
|
+
try {
|
|
978
|
+
body = fs.readFileSync(path.join(dir, it.file), "utf8");
|
|
979
|
+
}
|
|
980
|
+
catch {
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
if (budget < 300)
|
|
984
|
+
break;
|
|
985
|
+
const part = body.length > budget ? body.slice(0, budget) + `\n…(${it.lines} lines in all — the rest via huddle_context_read)` : body;
|
|
986
|
+
budget -= part.length;
|
|
987
|
+
out.push(`--- ${it.id} "${it.title}" ---\n${part}`);
|
|
988
|
+
}
|
|
989
|
+
return out.join("\n");
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* The SHARED SCREEN section of one task's launch prompt: the index, then the
|
|
993
|
+
* items the planner linked to this task (paths for images/PDFs, small ones
|
|
994
|
+
* inline). `images`: linked screenshots, for CLIs that attach images at launch.
|
|
995
|
+
*/
|
|
996
|
+
export function screenForTask(repoRoot, taskId) {
|
|
997
|
+
const { items, links, dir } = readMirror(repoRoot);
|
|
998
|
+
if (!items.length)
|
|
999
|
+
return { text: "", images: [], dir: null };
|
|
1000
|
+
const mine = items.filter((i) => (links[taskId] ?? []).includes(i.id));
|
|
1001
|
+
const out = [`SHARED SCREEN (one copy in the Room for the whole team, not pasted into your prompt):`, screenIndex(items)];
|
|
1002
|
+
if (mine.length) {
|
|
1003
|
+
out.push(`FOR THIS TASK the planner linked: ${mine.map((i) => i.id).join(", ")}`);
|
|
1004
|
+
for (const it of mine) {
|
|
1005
|
+
const abs = path.join(dir, it.file);
|
|
1006
|
+
const what = it.kind === "image" ? "an image" : it.kind === "pdf" ? "a PDF" : it.kind === "file" ? "a file" : "";
|
|
1007
|
+
if (what)
|
|
1008
|
+
out.push(`- ${it.id} is ${what} at ${abs} — open it with your file/image reading tool`);
|
|
1009
|
+
const body = inlineText(dir, it);
|
|
1010
|
+
if (body)
|
|
1011
|
+
out.push(`--- ${it.id} "${it.title}" ---\n${body}\n---`);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
out.push(READ_HINT);
|
|
1015
|
+
return {
|
|
1016
|
+
text: out.join("\n"),
|
|
1017
|
+
images: mine.filter((i) => i.kind === "image").map((i) => path.join(dir, i.file)),
|
|
1018
|
+
dir,
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
/** The short line seedBoard adds to a linked task's spec (teammates' machines have no mirror). */
|
|
1022
|
+
export function linkedLine(items) {
|
|
1023
|
+
if (!items.length)
|
|
1024
|
+
return "";
|
|
1025
|
+
return `\n\nOn the shared screen for this task: ${items.map((i) => `${i.id} (${kindWord(i)} "${i.title}")`).join(", ")} — open with huddle_context_read(id).`;
|
|
1026
|
+
}
|
|
1027
|
+
/** The planner's context ids, however they came back: "ctx-2", "ctx2", 2. */
|
|
1028
|
+
export function parseContextIds(v) {
|
|
1029
|
+
const arr = Array.isArray(v) ? v : typeof v === "string" ? v.split(/[\s,]+/) : [];
|
|
1030
|
+
return [...new Set(arr.map(normId).filter((x) => /^ctx-\d+$/.test(x)))];
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* seedBoard's hook: tie the plan's context ids to the new tasks and add a
|
|
1034
|
+
* one-line pointer to each linked task's spec (teammates' machines read the
|
|
1035
|
+
* spec, not this mirror). A one-task plan (or the template fallback) gets the
|
|
1036
|
+
* whole screen. Returns the ids linked per task.
|
|
1037
|
+
*/
|
|
1038
|
+
export function linkPlan(db, taskIds, planned) {
|
|
1039
|
+
const store = ContextStore.open(db);
|
|
1040
|
+
const items = store.list();
|
|
1041
|
+
if (!items.length)
|
|
1042
|
+
return taskIds.map(() => []);
|
|
1043
|
+
const anyLinks = planned.some((p) => p.context?.length);
|
|
1044
|
+
return taskIds.map((tid, k) => {
|
|
1045
|
+
const want = anyLinks ? planned[k]?.context ?? [] : planned.length === 1 ? items.map((i) => i.id) : [];
|
|
1046
|
+
const ok = store.link(tid, want);
|
|
1047
|
+
const line = linkedLine(items.filter((i) => ok.includes(i.id)));
|
|
1048
|
+
if (line)
|
|
1049
|
+
db.sql.prepare("UPDATE task SET spec = spec || ? WHERE id=?").run(line, tid);
|
|
1050
|
+
return ok;
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
/** huddle_context, _read, _search, _note — for local agents and (via the shared door) teammates'. */
|
|
1054
|
+
export function contextVerb(db, verb, body, agentId) {
|
|
1055
|
+
const store = ContextStore.open(db);
|
|
1056
|
+
// A teammate's agent is on another machine: never hand it this machine's paths.
|
|
1057
|
+
const remote = !!body?._shared;
|
|
1058
|
+
switch (verb) {
|
|
1059
|
+
case "context": {
|
|
1060
|
+
const items = store.list();
|
|
1061
|
+
return {
|
|
1062
|
+
status: 200,
|
|
1063
|
+
body: {
|
|
1064
|
+
items: items.map((i) => ({
|
|
1065
|
+
id: i.id,
|
|
1066
|
+
kind: i.kind,
|
|
1067
|
+
title: i.title,
|
|
1068
|
+
size: sizeOf(i),
|
|
1069
|
+
added_by: i.added_by,
|
|
1070
|
+
summary: i.brain_summary || i.summary,
|
|
1071
|
+
sections: i.sections.slice(0, 40).map((s, k) => `${k + 1}. ${s.title} (lines ${s.from}-${s.to})`),
|
|
1072
|
+
notes: i.notes.slice(-5).map((n) => `${n.by}: ${n.body}`),
|
|
1073
|
+
})),
|
|
1074
|
+
note: items.length ? READ_HINT : "Nothing on the shared screen yet.",
|
|
1075
|
+
},
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
case "context_read":
|
|
1079
|
+
return readVerb(db, store, body, agentId, remote);
|
|
1080
|
+
case "context_search": {
|
|
1081
|
+
const q = String(body.query ?? body.q ?? "").trim();
|
|
1082
|
+
if (!q)
|
|
1083
|
+
return { status: 400, body: { error: "give a query" } };
|
|
1084
|
+
let re;
|
|
1085
|
+
try {
|
|
1086
|
+
re = body.regex ? new RegExp(q, "i") : new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
|
|
1087
|
+
}
|
|
1088
|
+
catch (e) {
|
|
1089
|
+
return { status: 400, body: { error: `bad regex: ${e?.message ?? e}` } };
|
|
1090
|
+
}
|
|
1091
|
+
const matches = [];
|
|
1092
|
+
let more = 0;
|
|
1093
|
+
for (const it of store.list()) {
|
|
1094
|
+
const t = store.textOf(it);
|
|
1095
|
+
if (t === null)
|
|
1096
|
+
continue;
|
|
1097
|
+
t.split("\n").forEach((l, k) => {
|
|
1098
|
+
const m = re.exec(l);
|
|
1099
|
+
if (!m)
|
|
1100
|
+
return;
|
|
1101
|
+
if (matches.length >= 50)
|
|
1102
|
+
return void more++;
|
|
1103
|
+
// A match in a huge line: show the part around it.
|
|
1104
|
+
const at = m.index;
|
|
1105
|
+
const text = l.length > 300 ? `${at > 120 ? "…" : ""}${l.slice(Math.max(0, at - 120), at + 180)}${at + 180 < l.length ? "…" : ""}` : l;
|
|
1106
|
+
matches.push({ id: it.id, line: k + 1, text });
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
db.publish("context.search", { agent_id: agentId, payload: { query: q.slice(0, 200), by: agentId, hits: matches.length + more } });
|
|
1110
|
+
return {
|
|
1111
|
+
status: 200,
|
|
1112
|
+
body: { matches, ...(more ? { more: `${more} more match(es) — narrow the query` } : {}), note: matches.length ? "Read around a hit: huddle_context_read(id, from_line, to_line)." : "No matches on the shared screen." },
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
case "context_note": {
|
|
1116
|
+
const r = store.note(String(body.id ?? ""), String(body.note ?? body.body ?? ""), agentId);
|
|
1117
|
+
return "error" in r ? { status: 404, body: r } : { status: 200, body: { ok: true, note: "Pinned — every agent and the user sees it with the item." } };
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
return { status: 404, body: { error: "unknown verb" } };
|
|
1121
|
+
}
|
|
1122
|
+
/** Base64 file bytes over the wire are capped: a screenshot fits, a 25 MB PDF doesn't. */
|
|
1123
|
+
const WIRE_MAX = 8 * 1024 * 1024;
|
|
1124
|
+
const INLINE_IMAGE_MAX = 3.5 * 1024 * 1024;
|
|
1125
|
+
function readVerb(db, store, body, agentId, remote) {
|
|
1126
|
+
const it = store.get(String(body.id ?? ""));
|
|
1127
|
+
if (!it)
|
|
1128
|
+
return { status: 404, body: { error: `no item ${body.id ?? "(none)"} on the screen — huddle_context lists them` } };
|
|
1129
|
+
const abs = store.pathOf(it);
|
|
1130
|
+
const text = store.textOf(it);
|
|
1131
|
+
if (text === null) {
|
|
1132
|
+
db.publish("context.read", { agent_id: agentId, payload: { id: it.id, title: it.title, kind: it.kind, by: agentId } });
|
|
1133
|
+
let data = null;
|
|
1134
|
+
try {
|
|
1135
|
+
data = fs.readFileSync(abs);
|
|
1136
|
+
}
|
|
1137
|
+
catch {
|
|
1138
|
+
return { status: 410, body: { error: `${it.id}'s file is gone from the screen` } };
|
|
1139
|
+
}
|
|
1140
|
+
const res = { id: it.id, kind: it.kind, title: it.title, size: human(it.bytes), summary: it.summary };
|
|
1141
|
+
if (!remote) {
|
|
1142
|
+
res.path = abs;
|
|
1143
|
+
res.note = `Open this file with your image/file reading tool (e.g. Read ${abs}).${it.kind === "pdf" ? " (Install poppler's pdftotext and Huddle returns the PDF's text here too.)" : ""}`;
|
|
1144
|
+
}
|
|
1145
|
+
else if (data.length <= WIRE_MAX) {
|
|
1146
|
+
// The teammate's Plug saves these bytes on their machine and gives the agent that path.
|
|
1147
|
+
res.file = { name: `${it.id}.${it.file.split(".").pop()}`, mime: it.mime, data_base64: data.toString("base64") };
|
|
1148
|
+
}
|
|
1149
|
+
else
|
|
1150
|
+
res.note = `${it.id} is ${human(it.bytes)} — too big to send across machines; ask the host to paste the part you need`;
|
|
1151
|
+
if (it.kind === "image" && data.length <= INLINE_IMAGE_MAX)
|
|
1152
|
+
res.image = { mime: it.mime, data_base64: data.toString("base64") };
|
|
1153
|
+
return { status: 200, body: res };
|
|
1154
|
+
}
|
|
1155
|
+
const lines = text.split("\n");
|
|
1156
|
+
const total = lines.length;
|
|
1157
|
+
let from = Math.max(1, Math.floor(Number(body.from_line) || 1));
|
|
1158
|
+
let to = Math.floor(Number(body.to_line) || 0);
|
|
1159
|
+
let section;
|
|
1160
|
+
if (body.section !== undefined && body.section !== null && body.section !== "") {
|
|
1161
|
+
const secs = it.sections.length ? it.sections : sectionsOf(text);
|
|
1162
|
+
const n = Number(body.section);
|
|
1163
|
+
section = Number.isInteger(n) && n >= 1 && n <= secs.length ? secs[n - 1] : secs.find((s) => s.title.toLowerCase().includes(String(body.section).toLowerCase()));
|
|
1164
|
+
if (!section)
|
|
1165
|
+
return { status: 404, body: { error: `no section "${body.section}" in ${it.id}`, sections: secs.slice(0, 40).map((s, k) => `${k + 1}. ${s.title}`) } };
|
|
1166
|
+
from = section.from;
|
|
1167
|
+
to = section.to;
|
|
1168
|
+
}
|
|
1169
|
+
if (!to || to < from)
|
|
1170
|
+
to = from + READ_MAX_LINES - 1;
|
|
1171
|
+
to = Math.min(to, total, from + READ_MAX_LINES - 1);
|
|
1172
|
+
if (from > total)
|
|
1173
|
+
return { status: 200, body: { id: it.id, total_lines: total, text: "", note: `${it.id} has only ${total} lines` } };
|
|
1174
|
+
let chars = 0;
|
|
1175
|
+
const out = [];
|
|
1176
|
+
let last = from - 1;
|
|
1177
|
+
for (let k = from; k <= to; k++) {
|
|
1178
|
+
let l = lines[k - 1];
|
|
1179
|
+
if (l.length > LINE_MAX_CHARS)
|
|
1180
|
+
l = l.slice(0, LINE_MAX_CHARS) + ` …[line ${k} goes on for ${l.length - LINE_MAX_CHARS} more chars]`;
|
|
1181
|
+
if (chars + l.length > READ_MAX_CHARS && out.length)
|
|
1182
|
+
break;
|
|
1183
|
+
out.push(l);
|
|
1184
|
+
chars += l.length + 1;
|
|
1185
|
+
last = k;
|
|
1186
|
+
}
|
|
1187
|
+
db.publish("context.read", { agent_id: agentId, payload: { id: it.id, title: it.title, kind: it.kind, by: agentId, from, to: last } });
|
|
1188
|
+
return {
|
|
1189
|
+
status: 200,
|
|
1190
|
+
body: {
|
|
1191
|
+
id: it.id,
|
|
1192
|
+
title: it.title,
|
|
1193
|
+
from_line: from,
|
|
1194
|
+
to_line: last,
|
|
1195
|
+
total_lines: total,
|
|
1196
|
+
...(section ? { section: section.title } : {}),
|
|
1197
|
+
text: out.join("\n"),
|
|
1198
|
+
...(last < total ? { more: `lines ${last + 1}-${total} remain: huddle_context_read(id="${it.id}", from_line=${last + 1})` } : {}),
|
|
1199
|
+
...(it.notes.length ? { notes: it.notes.slice(-5).map((n) => `${n.by}: ${n.body}`) } : {}),
|
|
1200
|
+
},
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
/** Shared door for people (not agents): list, read, add text, note. Members add; guests may look. */
|
|
1204
|
+
export function contextShared(db, r, member) {
|
|
1205
|
+
const body = { ...(r.body ?? {}), _shared: true };
|
|
1206
|
+
if (r.method === "GET" && r.path === "/context")
|
|
1207
|
+
return contextVerb(db, "context", body, member.name);
|
|
1208
|
+
if (r.method === "POST" && r.path === "/context/read")
|
|
1209
|
+
return contextVerb(db, "context_read", body, member.name);
|
|
1210
|
+
if (r.method === "POST" && r.path === "/context/search")
|
|
1211
|
+
return contextVerb(db, "context_search", body, member.name);
|
|
1212
|
+
if (r.method === "POST" && r.path === "/context/note")
|
|
1213
|
+
return contextVerb(db, "context_note", body, member.name);
|
|
1214
|
+
if (r.method === "POST" && r.path === "/context/add") {
|
|
1215
|
+
if (member.role === "guest")
|
|
1216
|
+
return { status: 403, body: { error: "guests can look at the screen — not add to it" } };
|
|
1217
|
+
const store = ContextStore.open(db);
|
|
1218
|
+
// Text only over the wire (the body cap is 256 KB); redacted on arrival.
|
|
1219
|
+
const res = store.addText(String(body.text ?? ""), { title: body.title ? String(body.title).slice(0, 60) : undefined, source: "teammate", by: member.name });
|
|
1220
|
+
if (!res.ok)
|
|
1221
|
+
return { status: 400, body: { error: res.reason } };
|
|
1222
|
+
return { status: 200, body: { ok: true, id: res.item.id, duplicate: !!res.duplicate, redacted: res.redacted ?? 0 } };
|
|
1223
|
+
}
|
|
1224
|
+
return { status: 404, body: { error: "not found" } };
|
|
1225
|
+
}
|
|
1226
|
+
// ---- panels for the live view / story ----------------------------------------
|
|
1227
|
+
/** A few lines for the dashboard: items, newest first, and the latest notes. */
|
|
1228
|
+
export function screenPanel(db, max = 8) {
|
|
1229
|
+
let items;
|
|
1230
|
+
try {
|
|
1231
|
+
items = ContextStore.open(db).list();
|
|
1232
|
+
}
|
|
1233
|
+
catch {
|
|
1234
|
+
return [];
|
|
1235
|
+
}
|
|
1236
|
+
if (!items.length)
|
|
1237
|
+
return [];
|
|
1238
|
+
const out = [];
|
|
1239
|
+
for (const i of [...items].reverse().slice(0, Math.max(1, max - 3)))
|
|
1240
|
+
out.push(`📎 ${i.id} ${kindWord(i)} “${i.title}” · ${sizeOf(i)}`);
|
|
1241
|
+
if (items.length > max - 3)
|
|
1242
|
+
out.push(` …and ${items.length - (max - 3)} more`);
|
|
1243
|
+
const notes = items.flatMap((i) => i.notes).sort((a, b) => a.created_at - b.created_at).slice(-3);
|
|
1244
|
+
for (const n of notes)
|
|
1245
|
+
out.push(`📌 ${n.by} on ${n.item_id}: ${n.body}`);
|
|
1246
|
+
return out.slice(0, max);
|
|
1247
|
+
}
|
|
1248
|
+
// ---- optional brain summary (never blocks the prompt) --------------------------
|
|
1249
|
+
/**
|
|
1250
|
+
* One brain call for a long item (> 200 lines): a one-line summary for the
|
|
1251
|
+
* index. Cached by sha in summaries.json; time-boxed; silently skipped on failure.
|
|
1252
|
+
*/
|
|
1253
|
+
export async function brainSummary(store, item, brain, cwd) {
|
|
1254
|
+
if (!brain || item.lines <= 200)
|
|
1255
|
+
return null;
|
|
1256
|
+
const cacheFile = path.join(store.dir, "summaries.json");
|
|
1257
|
+
let cache = {};
|
|
1258
|
+
try {
|
|
1259
|
+
cache = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
|
|
1260
|
+
}
|
|
1261
|
+
catch {
|
|
1262
|
+
/* fresh cache */
|
|
1263
|
+
}
|
|
1264
|
+
if (cache[item.sha256]) {
|
|
1265
|
+
store.setBrainSummary(item.id, cache[item.sha256]);
|
|
1266
|
+
return cache[item.sha256];
|
|
1267
|
+
}
|
|
1268
|
+
const text = store.textOf(item);
|
|
1269
|
+
if (!text)
|
|
1270
|
+
return null;
|
|
1271
|
+
try {
|
|
1272
|
+
const r = await brain.think(`Summarize this ${item.kind} for a team of coding agents in ONE line (max 160 chars): what it is and what matters in it. No preamble.\n\n${text.slice(0, 12_000)}`, cwd, { timeoutMs: 45_000, selfContained: true });
|
|
1273
|
+
const line = r.ok ? r.text.trim().split("\n")[0].slice(0, 160) : "";
|
|
1274
|
+
if (!line)
|
|
1275
|
+
return null;
|
|
1276
|
+
cache[item.sha256] = line;
|
|
1277
|
+
fs.writeFileSync(cacheFile, JSON.stringify(cache));
|
|
1278
|
+
store.setBrainSummary(item.id, line);
|
|
1279
|
+
return line;
|
|
1280
|
+
}
|
|
1281
|
+
catch {
|
|
1282
|
+
return null;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
//# sourceMappingURL=context.js.map
|