@illuminis/comprism 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +281 -0
  3. package/out/agent/command.d.ts +86 -0
  4. package/out/agent/command.js +259 -0
  5. package/out/agent/render.d.ts +97 -0
  6. package/out/agent/render.js +255 -0
  7. package/out/agent/session.d.ts +175 -0
  8. package/out/agent/session.js +573 -0
  9. package/out/commands/ask.d.ts +1 -0
  10. package/out/commands/ask.js +146 -0
  11. package/out/commands/codemap.d.ts +2 -0
  12. package/out/commands/codemap.js +151 -0
  13. package/out/commands/commands-thin.d.ts +39 -0
  14. package/out/commands/commands-thin.js +182 -0
  15. package/out/commands/install.d.ts +163 -0
  16. package/out/commands/install.js +543 -0
  17. package/out/commands/keys.d.ts +55 -0
  18. package/out/commands/keys.js +344 -0
  19. package/out/commands/login.d.ts +9 -0
  20. package/out/commands/login.js +384 -0
  21. package/out/commands/repl.d.ts +1 -0
  22. package/out/commands/repl.js +752 -0
  23. package/out/commands/settings.d.ts +21 -0
  24. package/out/commands/settings.js +244 -0
  25. package/out/commands/welcome.d.ts +1 -0
  26. package/out/commands/welcome.js +196 -0
  27. package/out/executor/documents.d.ts +40 -0
  28. package/out/executor/documents.js +170 -0
  29. package/out/executor/files.d.ts +2 -0
  30. package/out/executor/files.js +360 -0
  31. package/out/executor/git.d.ts +48 -0
  32. package/out/executor/git.js +132 -0
  33. package/out/executor/hooks.d.ts +67 -0
  34. package/out/executor/hooks.js +247 -0
  35. package/out/executor/index.d.ts +29 -0
  36. package/out/executor/index.js +221 -0
  37. package/out/executor/notebook.d.ts +2 -0
  38. package/out/executor/notebook.js +147 -0
  39. package/out/executor/paths.d.ts +15 -0
  40. package/out/executor/paths.js +126 -0
  41. package/out/executor/shell.d.ts +41 -0
  42. package/out/executor/shell.js +336 -0
  43. package/out/graph/build.d.ts +45 -0
  44. package/out/graph/build.js +91 -0
  45. package/out/graph/facts.d.ts +47 -0
  46. package/out/graph/facts.js +12 -0
  47. package/out/graph/files.d.ts +45 -0
  48. package/out/graph/files.js +207 -0
  49. package/out/graph/read-locales.d.ts +29 -0
  50. package/out/graph/read-locales.js +246 -0
  51. package/out/graph/read-python.d.ts +11 -0
  52. package/out/graph/read-python.js +115 -0
  53. package/out/graph/read-typescript.d.ts +16 -0
  54. package/out/graph/read-typescript.js +292 -0
  55. package/out/graph/sync.d.ts +66 -0
  56. package/out/graph/sync.js +242 -0
  57. package/out/lib/attach.d.ts +62 -0
  58. package/out/lib/attach.js +228 -0
  59. package/out/lib/config.d.ts +93 -0
  60. package/out/lib/config.js +198 -0
  61. package/out/lib/connection.d.ts +73 -0
  62. package/out/lib/connection.js +188 -0
  63. package/out/lib/gateway.d.ts +239 -0
  64. package/out/lib/gateway.js +171 -0
  65. package/out/lib/prompt.d.ts +34 -0
  66. package/out/lib/prompt.js +108 -0
  67. package/out/lib/types.d.ts +417 -0
  68. package/out/lib/types.js +21 -0
  69. package/out/lib/ui.d.ts +114 -0
  70. package/out/lib/ui.js +265 -0
  71. package/out/lib/version.d.ts +24 -0
  72. package/out/lib/version.js +27 -0
  73. package/out/lib/voice.d.ts +50 -0
  74. package/out/lib/voice.js +218 -0
  75. package/out/postinstall.d.ts +2 -0
  76. package/out/postinstall.js +92 -0
  77. package/out/thin.d.ts +2 -0
  78. package/out/thin.js +259 -0
  79. package/package.json +101 -0
  80. package/scripts/read_python.py +270 -0
@@ -0,0 +1,242 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.status = status;
4
+ exports.refresh = refresh;
5
+ exports.coverageSentence = coverageSentence;
6
+ exports.readerReport = readerReport;
7
+ exports.pushFile = pushFile;
8
+ /**
9
+ * Keep the service's map of this project in step with the files on disk.
10
+ *
11
+ * A map that lies is worse than no map, because the agent trusts it. So the
12
+ * rule here is simple and it is never bent: before the map is used, the files
13
+ * are checked; what moved is read again; and if it cannot be brought up to
14
+ * date, the agent is told so rather than being handed a confident answer from
15
+ * yesterday.
16
+ *
17
+ * The check costs about ten milliseconds. A full read of a seven hundred file
18
+ * project costs a second or two, and a partial one costs almost nothing,
19
+ * because only the files that actually moved are read.
20
+ *
21
+ * Nothing here decides anything. The machine reports what it has; the service
22
+ * says what it needs; the machine reads exactly that and sends structure back.
23
+ */
24
+ const gateway_1 = require("../lib/gateway");
25
+ const build_1 = require("./build");
26
+ const read_python_1 = require("./read-python");
27
+ const read_typescript_1 = require("./read-typescript");
28
+ /** How many files' facts go in one call.
29
+ *
30
+ * A whole project in one request is several megabytes and fails in the least
31
+ * helpful way available: the request is rejected at the edge and nothing says
32
+ * which part was too big. Batches also mean a project that loses its
33
+ * connection half way has stored half its files rather than none, and the next
34
+ * run reads only what is still missing.
35
+ */
36
+ const BATCH = 120;
37
+ function reply(r) {
38
+ return (r.graph ?? {});
39
+ }
40
+ /** What the service holds for this project, and whether it matches disk. */
41
+ async function status(root, workspace, opts = {}) {
42
+ const state = (0, build_1.projectState)(root);
43
+ const answer = await (0, gateway_1.ask)({
44
+ intent: "graph", prompt: "", graphOp: "status", workspace,
45
+ graphPayload: {
46
+ fingerprint: state.fingerprint.hash,
47
+ // Ask for what the map holds in the same breath, when we are going to
48
+ // need it anyway. One round trip rather than two.
49
+ with_stamps: Boolean(opts.withStamps),
50
+ },
51
+ });
52
+ const graph = reply(answer);
53
+ return {
54
+ stamps: graph.stamps ?? null,
55
+ definedNames: graph.defined_names ?? [],
56
+ enabled: answer.granted !== false && graph.enabled !== false,
57
+ autoRefresh: graph.auto_refresh !== false,
58
+ fresh: Boolean(graph.fresh),
59
+ hasMap: Boolean(graph.has_map),
60
+ held: graph.held ?? null,
61
+ reachable: Boolean(answer.ok),
62
+ };
63
+ }
64
+ /**
65
+ * Bring the map up to date, reading only the files that moved.
66
+ *
67
+ * `force` reads everything again, which is what the manual refresh does: a
68
+ * person asking for a rebuild has usually just had an answer they did not
69
+ * believe, and telling them nothing needed doing is not an answer.
70
+ */
71
+ async function refresh(root, workspace, opts = {}) {
72
+ const started = Date.now();
73
+ const state = (0, build_1.projectState)(root);
74
+ const blank = {
75
+ current: false, enabled: true, message: "", filesRead: 0,
76
+ filesTotal: state.files.length, seconds: 0, unread: 0,
77
+ };
78
+ const now = await status(root, workspace, { withStamps: !opts.force });
79
+ if (!now.enabled) {
80
+ return { ...blank, enabled: false, current: false, message: "Your company has the code map switched off, so nothing was read or sent." };
81
+ }
82
+ if (!now.reachable) {
83
+ return { ...blank, current: false, message: "The service could not be reached, so the map was left as it is. Work carries on without it." };
84
+ }
85
+ if (now.fresh && !opts.force) {
86
+ return { ...blank, current: true, message: `The map already matches all ${state.files.length} files in this project.` };
87
+ }
88
+ // What the service already holds, so only what moved is read. It came back
89
+ // with the status above when there was a map to describe, which is one round
90
+ // trip saved on every refresh; a forced rebuild asks for none of it because
91
+ // it is going to read everything anyway.
92
+ const known = opts.force ? {} : (now.stamps ?? {});
93
+ // What the service already knows this project defines. Used to drop calls
94
+ // that could never resolve, before they are sent. Absent on a project it has
95
+ // not joined yet, which costs size and never correctness.
96
+ const knownNames = now.definedNames;
97
+ const changed = state.files
98
+ .filter((f) => known[f.rel] !== state.stamps[f.rel])
99
+ .map((f) => f.rel);
100
+ const removed = Object.keys(known).filter((p) => !(p in state.stamps));
101
+ const read = (0, build_1.readFacts)(root, changed, knownNames);
102
+ const unreadTotal = read.unread.python + read.unread.typescript;
103
+ // Sent in batches. Each call is self contained: a connection lost half way
104
+ // leaves the files already sent stored, and the next run reads only the rest.
105
+ const paths = Object.keys(read.facts);
106
+ for (let i = 0; i < paths.length; i += BATCH) {
107
+ const slice = paths.slice(i, i + BATCH);
108
+ const payload = {};
109
+ for (const rel of slice) {
110
+ payload[rel] = { ...read.facts[rel], stamp: state.stamps[rel] };
111
+ }
112
+ const sent = await (0, gateway_1.ask)({
113
+ intent: "graph", prompt: "", graphOp: "update", workspace,
114
+ graphPayload: {
115
+ changed: payload,
116
+ // The removals travel with the first batch, so a project whose files
117
+ // were deleted stops answering questions about them immediately rather
118
+ // than at the end of a long upload.
119
+ removed: i === 0 ? removed : [],
120
+ },
121
+ });
122
+ if (!sent.ok) {
123
+ return { ...blank, current: false, seconds: read.seconds, message: "The map could not be sent in full, so it was left as it is. Work carries on without it." };
124
+ }
125
+ }
126
+ const coverage = coverageSentence(read.unread, state.files.length);
127
+ const done = await (0, gateway_1.ask)({
128
+ intent: "graph", prompt: "", graphOp: "finish", workspace,
129
+ graphPayload: {
130
+ commit: state.commit,
131
+ fingerprint: state.fingerprint.hash,
132
+ file_count: state.files.length - unreadTotal,
133
+ unread_files: unreadTotal,
134
+ coverage,
135
+ read_seconds: String(read.seconds),
136
+ },
137
+ });
138
+ const seconds = Math.round((Date.now() - started) / 100) / 10;
139
+ if (!done.ok) {
140
+ return { ...blank, current: false, seconds, message: "The files were sent but the map could not be stamped as current, so it will be read again next time." };
141
+ }
142
+ return {
143
+ current: true,
144
+ enabled: true,
145
+ filesRead: paths.length,
146
+ filesTotal: state.files.length,
147
+ unread: unreadTotal,
148
+ seconds,
149
+ message: paths.length === state.files.length
150
+ ? `Read all ${paths.length} files in ${seconds}s.`
151
+ : `Read the ${paths.length} files that changed, of ${state.files.length}, in ${seconds}s.`,
152
+ };
153
+ }
154
+ /** What was read and what was not, in one sentence an answer can carry.
155
+ *
156
+ * Said out loud rather than hidden. A map that quietly covers less than it
157
+ * claims is the one failure worth engineering against: the agent believes it
158
+ * has seen everything, and writes a confident change that misses a caller. */
159
+ function coverageSentence(unread, total) {
160
+ const gaps = [];
161
+ if (unread.python) {
162
+ gaps.push(`${unread.python} Python files could not be read because this machine has no Python 3`);
163
+ }
164
+ if (unread.typescript) {
165
+ gaps.push(`${unread.typescript} TypeScript files could not be read because this installation has no TypeScript compiler`);
166
+ }
167
+ if (!gaps.length)
168
+ return `It covers all ${total} code files in this project.`;
169
+ return `It covers ${total - unread.python - unread.typescript} of ${total} code files: ${gaps.join("; ")}.`;
170
+ }
171
+ /** What this machine can read at all. Asked before anything else, so a person
172
+ * is told why their map is thin rather than left to guess. */
173
+ function readerReport() {
174
+ const python = Boolean((0, read_python_1.findPython)());
175
+ const typescript = Boolean((0, read_typescript_1.loadCompiler)());
176
+ if (python && typescript)
177
+ return "This machine can read Python, TypeScript and JavaScript.";
178
+ const missing = [];
179
+ if (!python)
180
+ missing.push("Python 3 is not on this machine, so Python files are not mapped");
181
+ if (!typescript)
182
+ missing.push("the TypeScript compiler is not installed beside this tool, so TypeScript and JavaScript files are not mapped");
183
+ return `Partly: ${missing.join(", and ")}.`;
184
+ }
185
+ /**
186
+ * Put ONE file back into the map, now, and wait for it.
187
+ *
188
+ * The fast path, and it exists because the slow one loses a race that matters.
189
+ * An agent writes a file and asks the map about it in the same step, because
190
+ * asking for several actions at once is what a well behaved agent does. A
191
+ * refresh started in the background after the write has not finished by the
192
+ * time the question runs, so the question reaches a map that is one file
193
+ * behind, and the agent is told the thing it just created does not exist.
194
+ *
195
+ * So this is awaited before the write's result goes back. One round trip: the
196
+ * file's facts and the project's new fingerprint travel together, and the
197
+ * service stores, stamps and rejoins in that single call.
198
+ *
199
+ * Failure is silent on purpose. A map that could not be updated must never turn
200
+ * a successful write into a failed one; the next check picks the file up.
201
+ */
202
+ async function pushFile(root, workspace, rels) {
203
+ const wanted = rels.filter(Boolean);
204
+ if (!wanted.length)
205
+ return false;
206
+ try {
207
+ const state = (0, build_1.projectState)(root);
208
+ // Only paths the map covers. Writing a README is not a change to the map,
209
+ // and paying a round trip to say so would tax every write an agent makes.
210
+ const indexed = new Set(state.files.map((f) => f.rel));
211
+ const present = wanted.filter((r) => indexed.has(r));
212
+ const gone = wanted.filter((r) => !indexed.has(r));
213
+ if (!present.length && !gone.length)
214
+ return false;
215
+ const read = present.length ? (0, build_1.readFacts)(root, present) : { facts: {}, unread: { python: 0, typescript: 0 }, seconds: 0, failures: [] };
216
+ const changed = {};
217
+ for (const rel of Object.keys(read.facts)) {
218
+ changed[rel] = { ...read.facts[rel], stamp: state.stamps[rel] };
219
+ }
220
+ const sent = await (0, gateway_1.ask)({
221
+ intent: "graph", prompt: "", graphOp: "update", workspace,
222
+ graphPayload: {
223
+ changed,
224
+ // A file the agent deleted or moved away leaves the map in the same
225
+ // call, or the map keeps answering questions about something gone.
226
+ removed: gone,
227
+ // The stamp, so the service can mark the map current without a second
228
+ // trip. This is what makes the write and the question safe in one step.
229
+ commit: state.commit,
230
+ fingerprint: state.fingerprint.hash,
231
+ file_count: state.files.length,
232
+ unread_files: 0,
233
+ coverage: coverageSentence({ python: 0, typescript: 0 }, state.files.length),
234
+ read_seconds: String(read.seconds),
235
+ },
236
+ });
237
+ return Boolean(sent.ok);
238
+ }
239
+ catch {
240
+ return false;
241
+ }
242
+ }
@@ -0,0 +1,62 @@
1
+ /** One file that was found, and what the server made of it. */
2
+ export interface Attached {
3
+ id: string;
4
+ filename: string;
5
+ /** What the extractor called it: `pdf`, `png`, `text` and so on. */
6
+ format: string;
7
+ /** Characters of text pulled out. Zero for a picture, which is not a failure. */
8
+ chars: number;
9
+ /** The text was longer than one turn can carry and was cut. */
10
+ truncated: boolean;
11
+ isImage: boolean;
12
+ }
13
+ /** A path somebody named, before anything has been read. */
14
+ export interface Mention {
15
+ /** Exactly as it appeared, so it can be taken back out of the sentence. */
16
+ raw: string;
17
+ /** Resolved, unescaped and known to exist. */
18
+ file: string;
19
+ }
20
+ /**
21
+ * Take the shell's escaping back off a path.
22
+ *
23
+ * A dragged file arrives as `/Users/me/My\ Report.pdf`, and a pasted one is
24
+ * often in quotes. Both are the shell's way of saying "this is one thing", and
25
+ * neither is part of the name.
26
+ */
27
+ export declare function unescapePath(token: string): string;
28
+ /**
29
+ * Every file the typed line points at, in the order they were named.
30
+ *
31
+ * A token that looks like a path but is not on the disk is left alone and stays
32
+ * part of the question. Somebody writing about a path they plan to create is
33
+ * doing something perfectly ordinary, and refusing their line over it would be
34
+ * the tool arguing with them.
35
+ */
36
+ export declare function mentions(line: string, cwd: string): Mention[];
37
+ /**
38
+ * The question with the long paths taken out and the file names left in.
39
+ *
40
+ * "summarize @/Users/me/Downloads/Q3 Board Pack.pdf" is a worse question than
41
+ * "summarize Q3 Board Pack.pdf", and the second is what the model should be
42
+ * reading: the file is already in front of it under that name, so the path adds
43
+ * nothing but a place for the model to try to open something it cannot reach.
44
+ */
45
+ export declare function withoutPaths(line: string, found: Mention[]): string;
46
+ /**
47
+ * Hand the files to the server and get back the ids the question will carry.
48
+ *
49
+ * One at a time and in order, because the caps are per file and a refusal names
50
+ * which file it is refusing. A file that cannot be read does not stop the
51
+ * others: the person gets their answer about the four that worked, and is told
52
+ * plainly about the one that did not.
53
+ */
54
+ export declare function upload(baseUrl: string, credential: string, files: string[]): Promise<{
55
+ attached: Attached[];
56
+ refused: {
57
+ file: string;
58
+ why: string;
59
+ }[];
60
+ }>;
61
+ /** What was attached, in one line each, for the person to see before it runs. */
62
+ export declare function describe(a: Attached): string;
@@ -0,0 +1,228 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.unescapePath = unescapePath;
37
+ exports.mentions = mentions;
38
+ exports.withoutPaths = withoutPaths;
39
+ exports.upload = upload;
40
+ exports.describe = describe;
41
+ /**
42
+ * Files handed to a question, from a terminal.
43
+ *
44
+ * ── What a person actually does ───────────────────────────────────────────
45
+ *
46
+ * They drag a file onto the terminal window. Every terminal answers that by
47
+ * typing the file's path at the cursor, with spaces backslash-escaped, which is
48
+ * why a path is not simply "the text up to the next space". Or they type `@`
49
+ * and a path, which is what everybody who has used one of these tools expects.
50
+ *
51
+ * Both end up here, and both mean the same thing: read this, it is what my
52
+ * question is about.
53
+ *
54
+ * ── Why the file is uploaded and not just named ───────────────────────────
55
+ *
56
+ * The agent can already open any file inside the folder it is working in, so
57
+ * naming one would have been cheaper. It would also have been wrong three ways:
58
+ * a screenshot is not text and could never be read that way, a PDF or a
59
+ * spreadsheet needs extracting before it means anything, and a file somewhere
60
+ * else on the disk is outside the folder the agent is allowed to touch. A
61
+ * person who attaches a file has said they want it read, so it is read.
62
+ *
63
+ * The reading happens on the server, by the same component the web composer
64
+ * uses. Nothing is stored: the text lives in memory for half an hour and then
65
+ * it is gone.
66
+ *
67
+ * ── What counts as a path, and what deliberately does not ─────────────────
68
+ *
69
+ * Only something said on purpose. An absolute path, a path starting `~/`, `./`
70
+ * or `../`, anything quoted, and anything after an `@`. A bare word like
71
+ * `package.json` mentioned in a sentence is NOT attached, even when a file of
72
+ * that name is sitting right there: the agent can open it itself for nothing,
73
+ * and silently uploading every filename somebody mentions would surprise them
74
+ * and charge them for it.
75
+ */
76
+ const fs = __importStar(require("node:fs"));
77
+ const os = __importStar(require("node:os"));
78
+ const path = __importStar(require("node:path"));
79
+ /**
80
+ * Take the shell's escaping back off a path.
81
+ *
82
+ * A dragged file arrives as `/Users/me/My\ Report.pdf`, and a pasted one is
83
+ * often in quotes. Both are the shell's way of saying "this is one thing", and
84
+ * neither is part of the name.
85
+ */
86
+ function unescapePath(token) {
87
+ let out = token.trim();
88
+ if ((out.startsWith('"') && out.endsWith('"'))
89
+ || (out.startsWith("'") && out.endsWith("'"))) {
90
+ out = out.slice(1, -1);
91
+ }
92
+ else {
93
+ out = out.replace(/\\(.)/g, '$1');
94
+ }
95
+ return out;
96
+ }
97
+ /** `~/notes.md` against this person's home folder. */
98
+ function expand(file, cwd) {
99
+ const home = os.homedir();
100
+ const full = file === '~' ? home
101
+ : file.startsWith('~/') ? path.join(home, file.slice(2))
102
+ : path.resolve(cwd, file);
103
+ return full;
104
+ }
105
+ function isFile(file) {
106
+ try {
107
+ return fs.statSync(file).isFile();
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ }
113
+ /**
114
+ * Every file the typed line points at, in the order they were named.
115
+ *
116
+ * A token that looks like a path but is not on the disk is left alone and stays
117
+ * part of the question. Somebody writing about a path they plan to create is
118
+ * doing something perfectly ordinary, and refusing their line over it would be
119
+ * the tool arguing with them.
120
+ */
121
+ function mentions(line, cwd) {
122
+ const found = [];
123
+ const seen = new Set();
124
+ const consider = (raw, candidate) => {
125
+ const file = expand(unescapePath(candidate), cwd);
126
+ if (!isFile(file) || seen.has(file))
127
+ return;
128
+ seen.add(file);
129
+ found.push({ raw, file });
130
+ };
131
+ // Quoted first, because a quoted path may contain the spaces that would
132
+ // otherwise end a token, and the quotes are the person saying exactly that.
133
+ for (const m of line.matchAll(/"([^"]+)"|'([^']+)'/g)) {
134
+ consider(m[0], m[1] ?? m[2] ?? '');
135
+ }
136
+ // Then `@`, which is treated generously on purpose.
137
+ //
138
+ // A dragged file arrives escaped and a pasted one is usually quoted, but
139
+ // somebody who TYPES `@` and then a folder with a space in its name has said
140
+ // what they mean as plainly as it can be said. So everything after the `@` is
141
+ // tried, then the last word is dropped and it is tried again, until a real
142
+ // file turns up or the words run out. `@/Users/me/My Report.md in one line`
143
+ // finds the report and leaves the rest of the sentence alone.
144
+ for (const m of line.matchAll(/(?:^|\s)@(\S.*)$/gm)) {
145
+ const rest = m[1] ?? '';
146
+ for (let end = rest.length; end > 0; end = rest.lastIndexOf(' ', end - 1)) {
147
+ const before = found.length;
148
+ consider(`@${rest.slice(0, end)}`, rest.slice(0, end));
149
+ if (found.length > before)
150
+ break;
151
+ }
152
+ }
153
+ // Then every run of non-space, with `\ ` counting as part of the run rather
154
+ // than as the end of it. This is the dragged-file case and the `@` case.
155
+ for (const m of line.matchAll(/(?:\\.|[^\s\\])+/g)) {
156
+ const raw = m[0];
157
+ if (!raw)
158
+ continue;
159
+ const body = raw.startsWith('@') ? raw.slice(1) : raw;
160
+ const deliberate = raw.startsWith('@')
161
+ || /^[/~]/.test(body) || body.startsWith('./') || body.startsWith('../');
162
+ if (!deliberate)
163
+ continue;
164
+ consider(raw, body);
165
+ }
166
+ return found;
167
+ }
168
+ /**
169
+ * The question with the long paths taken out and the file names left in.
170
+ *
171
+ * "summarize @/Users/me/Downloads/Q3 Board Pack.pdf" is a worse question than
172
+ * "summarize Q3 Board Pack.pdf", and the second is what the model should be
173
+ * reading: the file is already in front of it under that name, so the path adds
174
+ * nothing but a place for the model to try to open something it cannot reach.
175
+ */
176
+ function withoutPaths(line, found) {
177
+ let out = line;
178
+ for (const m of found)
179
+ out = out.replace(m.raw, path.basename(m.file));
180
+ return out.replace(/\s{2,}/g, ' ').trim();
181
+ }
182
+ /**
183
+ * Hand the files to the server and get back the ids the question will carry.
184
+ *
185
+ * One at a time and in order, because the caps are per file and a refusal names
186
+ * which file it is refusing. A file that cannot be read does not stop the
187
+ * others: the person gets their answer about the four that worked, and is told
188
+ * plainly about the one that did not.
189
+ */
190
+ async function upload(baseUrl, credential, files) {
191
+ const root = baseUrl.replace(/\/+$/, '');
192
+ const attached = [];
193
+ const refused = [];
194
+ for (const file of files) {
195
+ try {
196
+ const form = new FormData();
197
+ form.append('file', new Blob([fs.readFileSync(file)]), path.basename(file));
198
+ const res = await fetch(`${root}/api/v1/agent/attachments`, {
199
+ method: 'POST',
200
+ headers: { Authorization: `Bearer ${credential}` },
201
+ body: form,
202
+ });
203
+ if (!res.ok) {
204
+ const body = await res.json().catch(() => ({}));
205
+ refused.push({ file, why: body.detail || `the server said ${res.status}` });
206
+ continue;
207
+ }
208
+ const row = await res.json();
209
+ attached.push({
210
+ id: row.id, filename: row.filename, format: row.format,
211
+ chars: row.chars, truncated: row.truncated, isImage: row.is_image,
212
+ });
213
+ }
214
+ catch (err) {
215
+ refused.push({ file, why: err.message });
216
+ }
217
+ }
218
+ return { attached, refused };
219
+ }
220
+ /** What was attached, in one line each, for the person to see before it runs. */
221
+ function describe(a) {
222
+ if (a.isImage)
223
+ return `${a.filename} read as a picture`;
224
+ const size = a.chars >= 1000
225
+ ? `${Math.round(a.chars / 1000)}k characters`
226
+ : `${a.chars} characters`;
227
+ return `${a.filename} ${size}${a.truncated ? ', shortened to fit' : ''}`;
228
+ }
@@ -0,0 +1,93 @@
1
+ import type { ExecutionMode, FooterConfig, Provider } from './types';
2
+ export declare const HOME_DIR_NAME = ".comprism";
3
+ export interface ContentPolicy {
4
+ /**
5
+ * Store request and response text alongside the records. Default false, and
6
+ * every screen and report is built to work without it. Turning it on is a
7
+ * tenant's explicit decision, never a default and never a nudge.
8
+ */
9
+ storeText: boolean;
10
+ }
11
+ export interface ProviderConfig {
12
+ enabled: boolean;
13
+ /** Shell command printing the key on stdout. Its OUTPUT is never stored. */
14
+ keyCommand?: string;
15
+ }
16
+ export interface Config {
17
+ config_version: 1;
18
+ /** Who the records are attributed to locally. Never an email by default. */
19
+ user: string;
20
+ mode: ExecutionMode;
21
+ providers: Record<Provider, ProviderConfig>;
22
+ /** Model ids the policy may consider. Empty means the whole catalog. */
23
+ allowedModels: string[];
24
+ /**
25
+ * The model a session and the SDK dispatch unless the caller names another.
26
+ *
27
+ * This exists because of a real failure. The client used to dispatch the
28
+ * cheapest catalog entry for whatever band the structural classifier
29
+ * guessed - so a short research question was banded `simple`, answered by the
30
+ * cheapest model, and the answer was the plausible-but-useless kind this
31
+ * entire product exists to warn people about.
32
+ *
33
+ * Two things were wrong with that. It contradicted the release's own rule
34
+ * that v1 observes and never alters dispatch. And it made a cost-quality
35
+ * trade-off on the user's behalf using exactly the evidence the estimator
36
+ * refuses to guess from - none. With no evidence, the honest default is a
37
+ * capable model the user chose once, not the cheapest one we inferred.
38
+ */
39
+ defaultModel: string;
40
+ contentPolicy: ContentPolicy;
41
+ /**
42
+ * Deployment context the tenant declares rather than the product infers.
43
+ *
44
+ * Everything here is optional and inferred from the workspace when absent.
45
+ * `verification` is the one worth setting by hand: whether anything checks the
46
+ * output before it reaches someone outside the company is what decides how
47
+ * expensive a wrong answer is, and a person knows it where a heuristic guesses.
48
+ */
49
+ context: {
50
+ role?: string | null;
51
+ workspace?: string | null;
52
+ application?: string | null;
53
+ verification?: 'automated_tests' | 'human_review' | 'none' | 'unknown';
54
+ };
55
+ /** Port the local proxy listens on. */
56
+ proxyPort: number;
57
+ /**
58
+ * The stats footer the proxy appends to a completed answer.
59
+ *
60
+ * The proxy is the only surface with no result object and no widget area, so
61
+ * it is the only one that writes into the message. Turning this off restores
62
+ * byte-faithful passthrough in both directions; inbound stripping still runs,
63
+ * because a footer already sitting in someone's history has to come out
64
+ * whether or not new ones are being written.
65
+ */
66
+ footer: FooterConfig;
67
+ created: string;
68
+ updated: string;
69
+ }
70
+ export type Keys = Partial<Record<Provider, string>>;
71
+ export declare function homeDir(): string;
72
+ /**
73
+ * Create the home directory at mode 700 if it is not there.
74
+ *
75
+ * 700 rather than the default 755: other local accounts have no business
76
+ * reading someone's work records, and a directory created world-readable is
77
+ * never noticed afterwards.
78
+ */
79
+ export declare function ensureHome(): string;
80
+ export declare function configPath(): string;
81
+ export declare function defaultConfig(): Config;
82
+ export declare function loadConfig(): Config;
83
+ export declare function saveConfig(cfg: Config): void;
84
+ /**
85
+ * Resolve keys for every enabled provider. Environment first, then the
86
+ * configured key command. A provider whose key does not resolve is simply not
87
+ * available; that is not an error until something tries to call it.
88
+ */
89
+ export declare function resolveKeys(cfg: Config): Keys;
90
+ export declare function availableProviders(keys: Keys): Provider[];
91
+ /** Where the key for a provider would come from, for `setup` to report. */
92
+ export declare function keySource(cfg: Config, provider: Provider): 'env' | 'command' | 'none';
93
+ export declare function envVarName(provider: Provider): string;