koneck 2.62.0 → 2.64.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.
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The computer's own folder picker, opened by the server.
3
+ *
4
+ * A browser cannot do this, and it is worth being precise about why rather than treating it as a
5
+ * missing feature. `<input type="file" webkitdirectory>` hands back file entries with names relative
6
+ * to the chosen folder and no absolute path; `showDirectoryPicker()` hands back a handle and no
7
+ * path either. Both withhold it deliberately — a page that learned where you keep your files has
8
+ * learned something about you that it was not given.
9
+ *
10
+ * But KONECK's server is on the same machine as the files, and acting through it already requires
11
+ * loopback and the token. So the *server* opens the dialog: the real one, the one every other
12
+ * application on the machine uses, and the path comes back because the process asking is entitled
13
+ * to know it.
14
+ *
15
+ * Two honest limits, both reported rather than papered over. The dialog appears on the machine
16
+ * running KONECK — which is the same machine, because acting is loopback-only, but somebody
17
+ * tunnelling a port should know that. And a machine with no desktop session has no dialog to show:
18
+ * over SSH there is nothing to put on screen, so the answer is to say so and let the built-in
19
+ * browser do the job instead.
20
+ */
21
+ /** What came back, or why nothing did. */
22
+ export type FolderChoice = {
23
+ ok: true;
24
+ path: string;
25
+ }
26
+ /** The person closed the dialog. Not an error, and must not be reported as one. */
27
+ | {
28
+ ok: false;
29
+ cancelled: true;
30
+ } | {
31
+ ok: false;
32
+ cancelled: false;
33
+ reason: string;
34
+ };
35
+ /**
36
+ * Runs a dialog and stops waiting if it stops answering.
37
+ *
38
+ * execa's own timeout signals the process it started and then waits for its pipes to close, which
39
+ * is not the same thing — a dialog that dies in a way execa does not notice left the request open
40
+ * for the full two minutes. Measured: a dialog killed from outside took 120,027ms to settle, while
41
+ * one that exited cleanly took 2,588ms. So the wait is raced against a deadline and the process
42
+ * group is signalled, which is the same shape as the fix the command runner already needed.
43
+ */
44
+ export declare function runDialog(file: string, args: readonly string[],
45
+ /** Injectable so the giving-up path can be tested without waiting two minutes for it. */
46
+ waitMs?: number): Promise<{
47
+ code: number | undefined;
48
+ out: string;
49
+ err: string;
50
+ gaveUp: boolean;
51
+ }>;
52
+ /** Whether a desktop session exists to show a dialog on. */
53
+ export declare function hasDesktop(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
54
+ /** Which dialog tool would be used, or null when none is installed. */
55
+ export declare function dialogAvailable(platform?: NodeJS.Platform): Promise<string | null>;
56
+ /**
57
+ * Opens the folder dialog and waits for an answer.
58
+ *
59
+ * A generous timeout, because the answer is a person deciding: two minutes is long enough to think
60
+ * and short enough that a dialog nobody is looking at does not hold a handle for ever.
61
+ */
62
+ export declare function chooseFolder(startIn?: string, title?: string, platform?: NodeJS.Platform): Promise<FolderChoice>;
63
+ //# sourceMappingURL=folder-dialog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"folder-dialog.d.ts","sourceRoot":"","sources":["../src/folder-dialog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAKH,0CAA0C;AAC1C,MAAM,MAAM,YAAY,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE;AAC5B,mFAAmF;GACjF;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,SAAS,EAAE,IAAI,CAAA;CAAE,GAC9B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,SAAS,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAyBpD;;;;;;;;GAQG;AACH,wBAAsB,SAAS,CAC7B,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE;AACrC,yFAAyF;AACzF,MAAM,SAAiB,GACtB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,CAAC,CAyBlF;AAKD,4DAA4D;AAC5D,wBAAgB,UAAU,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,EAAE,QAAQ,kBAAmB,GAAG,OAAO,CAGrG;AAED,uEAAuE;AACvE,wBAAsB,eAAe,CACnC,QAAQ,kBAAmB,GAC1B,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CASxB;AAED;;;;;GAKG;AACH,wBAAsB,YAAY,CAChC,OAAO,SAAe,EACtB,KAAK,SAAmB,EACxB,QAAQ,kBAAmB,GAC1B,OAAO,CAAC,YAAY,CAAC,CA8CvB"}
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The computer's own folder picker, opened by the server.
3
+ *
4
+ * A browser cannot do this, and it is worth being precise about why rather than treating it as a
5
+ * missing feature. `<input type="file" webkitdirectory>` hands back file entries with names relative
6
+ * to the chosen folder and no absolute path; `showDirectoryPicker()` hands back a handle and no
7
+ * path either. Both withhold it deliberately — a page that learned where you keep your files has
8
+ * learned something about you that it was not given.
9
+ *
10
+ * But KONECK's server is on the same machine as the files, and acting through it already requires
11
+ * loopback and the token. So the *server* opens the dialog: the real one, the one every other
12
+ * application on the machine uses, and the path comes back because the process asking is entitled
13
+ * to know it.
14
+ *
15
+ * Two honest limits, both reported rather than papered over. The dialog appears on the machine
16
+ * running KONECK — which is the same machine, because acting is loopback-only, but somebody
17
+ * tunnelling a port should know that. And a machine with no desktop session has no dialog to show:
18
+ * over SSH there is nothing to put on screen, so the answer is to say so and let the built-in
19
+ * browser do the job instead.
20
+ */
21
+ import os from 'os';
22
+ import { existsSync } from 'fs';
23
+ /**
24
+ * The ways to ask, in the order worth trying.
25
+ *
26
+ * zenity and kdialog are the GTK and KDE dialogs, so one of them is present on most desktops; yad
27
+ * is a fork of zenity that some distributions ship instead. macOS has AppleScript, which drives the
28
+ * real Finder dialog. Windows has PowerShell, whose folder browser is the shell's own.
29
+ */
30
+ const LINUX_DIALOGS = [
31
+ { file: 'zenity', args: (title, startIn) => ['--file-selection', '--directory', `--title=${title}`, `--filename=${startIn}/`] },
32
+ { file: 'kdialog', args: (title, startIn) => ['--getexistingdirectory', startIn, '--title', title] },
33
+ { file: 'qarma', args: (title, startIn) => ['--file-selection', '--directory', `--title=${title}`, `--filename=${startIn}/`] },
34
+ { file: 'yad', args: (title, startIn) => ['--file-selection', '--directory', `--title=${title}`, `--filename=${startIn}/`] },
35
+ ];
36
+ /**
37
+ * Runs a dialog and stops waiting if it stops answering.
38
+ *
39
+ * execa's own timeout signals the process it started and then waits for its pipes to close, which
40
+ * is not the same thing — a dialog that dies in a way execa does not notice left the request open
41
+ * for the full two minutes. Measured: a dialog killed from outside took 120,027ms to settle, while
42
+ * one that exited cleanly took 2,588ms. So the wait is raced against a deadline and the process
43
+ * group is signalled, which is the same shape as the fix the command runner already needed.
44
+ */
45
+ export async function runDialog(file, args,
46
+ /** Injectable so the giving-up path can be tested without waiting two minutes for it. */
47
+ waitMs = DIALOG_WAIT_MS) {
48
+ const { execa } = await import('execa');
49
+ const child = execa(file, [...args], { reject: false, detached: true });
50
+ const GAVE_UP = Symbol('gave-up');
51
+ let timer;
52
+ const deadline = new Promise(resolve => {
53
+ timer = setTimeout(() => resolve(GAVE_UP), waitMs);
54
+ });
55
+ try {
56
+ const settled = await Promise.race([child.then(r => r), deadline]);
57
+ if (settled === GAVE_UP) {
58
+ // The whole group, because the dialog may have re-executed itself into a child.
59
+ try {
60
+ if (child.pid)
61
+ process.kill(-child.pid, 'SIGKILL');
62
+ }
63
+ catch {
64
+ try {
65
+ child.kill('SIGKILL');
66
+ }
67
+ catch { /* already gone */ }
68
+ }
69
+ return { code: undefined, out: '', err: '', gaveUp: true };
70
+ }
71
+ return {
72
+ code: settled.exitCode ?? undefined,
73
+ out: String(settled.stdout ?? ''),
74
+ err: String(settled.stderr ?? ''),
75
+ gaveUp: false,
76
+ };
77
+ }
78
+ finally {
79
+ if (timer)
80
+ clearTimeout(timer);
81
+ }
82
+ }
83
+ /** How long a dialog nobody is answering may hold a request open. */
84
+ const DIALOG_WAIT_MS = 120_000;
85
+ /** Whether a desktop session exists to show a dialog on. */
86
+ export function hasDesktop(env = process.env, platform = process.platform) {
87
+ if (platform === 'darwin' || platform === 'win32')
88
+ return true;
89
+ return !!(env['DISPLAY'] || env['WAYLAND_DISPLAY']);
90
+ }
91
+ /** Which dialog tool would be used, or null when none is installed. */
92
+ export async function dialogAvailable(platform = process.platform) {
93
+ if (platform === 'darwin')
94
+ return 'osascript';
95
+ if (platform === 'win32')
96
+ return 'powershell';
97
+ const { execa } = await import('execa');
98
+ for (const dialog of LINUX_DIALOGS) {
99
+ const found = await execa('command', ['-v', dialog.file], { shell: true, reject: false });
100
+ if (found.exitCode === 0 && String(found.stdout).trim() !== '')
101
+ return dialog.file;
102
+ }
103
+ return null;
104
+ }
105
+ /**
106
+ * Opens the folder dialog and waits for an answer.
107
+ *
108
+ * A generous timeout, because the answer is a person deciding: two minutes is long enough to think
109
+ * and short enough that a dialog nobody is looking at does not hold a handle for ever.
110
+ */
111
+ export async function chooseFolder(startIn = os.homedir(), title = 'Open a project', platform = process.platform) {
112
+ if (!hasDesktop()) {
113
+ return { ok: false, cancelled: false, reason: 'This machine has no desktop session, so there is no dialog to show — KONECK is probably '
114
+ + 'running over SSH. Use the folder list instead.' };
115
+ }
116
+ const from = existsSync(startIn) ? startIn : os.homedir();
117
+ if (platform === 'darwin') {
118
+ // AppleScript drives the real Finder dialog, and returns POSIX path text.
119
+ const script = `set chosen to choose folder with prompt ${JSON.stringify(title)} `
120
+ + `default location POSIX file ${JSON.stringify(from)}\n`
121
+ + 'return POSIX path of chosen';
122
+ const done = await runDialog('osascript', ['-e', script]);
123
+ if (done.gaveUp)
124
+ return gaveUp();
125
+ if (done.code !== 0)
126
+ return cancelledOrFailed(done.err, done.code);
127
+ return finish(done.out);
128
+ }
129
+ if (platform === 'win32') {
130
+ const script = 'Add-Type -AssemblyName System.Windows.Forms; '
131
+ + '$d = New-Object System.Windows.Forms.FolderBrowserDialog; '
132
+ + `$d.Description = ${JSON.stringify(title)}; `
133
+ + `$d.SelectedPath = ${JSON.stringify(from)}; `
134
+ + 'if ($d.ShowDialog() -eq "OK") { $d.SelectedPath } else { exit 1 }';
135
+ const done = await runDialog('powershell.exe', ['-NoProfile', '-Command', script]);
136
+ if (done.gaveUp)
137
+ return gaveUp();
138
+ if (done.code !== 0)
139
+ return cancelledOrFailed(done.err, done.code);
140
+ return finish(done.out);
141
+ }
142
+ const tool = await dialogAvailable(platform);
143
+ if (!tool) {
144
+ return { ok: false, cancelled: false, reason: 'No folder dialog is installed. On most desktops that is the zenity package (or kdialog on '
145
+ + 'KDE). Use the folder list instead.' };
146
+ }
147
+ const dialog = LINUX_DIALOGS.find(d => d.file === tool);
148
+ const done = await runDialog(dialog.file, dialog.args(title, from));
149
+ if (done.gaveUp)
150
+ return gaveUp();
151
+ // Every one of these exits non-zero when the dialog is dismissed, which is a choice not to choose
152
+ // rather than a failure, and must not be reported as one.
153
+ if (done.code !== 0)
154
+ return cancelledOrFailed(done.err, done.code);
155
+ return finish(done.out);
156
+ }
157
+ function gaveUp() {
158
+ return { ok: false, cancelled: false, reason: 'the folder dialog stopped answering and was closed — try the folder list instead' };
159
+ }
160
+ function cancelledOrFailed(stderr, code) {
161
+ const said = String(stderr ?? '').trim();
162
+ // A dismissed dialog says nothing; a broken one says something.
163
+ if (said === '')
164
+ return { ok: false, cancelled: true };
165
+ return { ok: false, cancelled: false,
166
+ reason: `the folder dialog failed${code !== undefined ? ` (${code})` : ''}: ${said}` };
167
+ }
168
+ function finish(stdout) {
169
+ // zenity can return several paths separated by | when multiple selection is on; take the first.
170
+ const path = String(stdout ?? '').split('|')[0].trim().replace(/\/+$/, '');
171
+ if (path === '')
172
+ return { ok: false, cancelled: true };
173
+ return { ok: true, path };
174
+ }
175
+ //# sourceMappingURL=folder-dialog.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"folder-dialog.js","sourceRoot":"","sources":["../src/folder-dialog.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * What a session is called.
3
+ *
4
+ * It was called whatever you typed first, truncated to eighty characters, for ever. So a session
5
+ * that began "hi" and went on to build a ledger page was called "hi" — and the picker, which exists
6
+ * to answer "which of these was the one about X", answered "hi", "ok", "continue" and "what is 2+2"
7
+ * for a week's work.
8
+ *
9
+ * Two mistakes in one line of code. The first message is often not the request: it is a greeting, an
10
+ * acknowledgement, a "carry on". And a title set once cannot follow a session that turned into
11
+ * something else, which is what sessions do.
12
+ *
13
+ * So the name is derived from the substance and revised as the substance changes. Mechanically
14
+ * first, because that always works and costs nothing: the earliest message that actually asks for
15
+ * something, cleaned up. Then, once there is enough to summarise, the model is asked for a short
16
+ * one — which is the only way to get "Stokvel dashboard with recharts" out of a conversation that
17
+ * happens to open with "hi". That ask is cheap, happens once, and never blocks a turn; when it
18
+ * fails the mechanical name stands.
19
+ */
20
+ /**
21
+ * Messages, as much of them as naming needs.
22
+ *
23
+ * Deliberately loose. The provider's own message type has a narrower role union and several content
24
+ * shapes, some of which omit content altogether, and a stricter shape here would refuse the very
25
+ * array it exists to read. Naming only needs to know who said something and what it said.
26
+ */
27
+ export interface Said {
28
+ role?: unknown;
29
+ content?: unknown;
30
+ }
31
+ export declare function isPleasantry(text: string): boolean;
32
+ /** The text of a message, whatever shape its content came in. */
33
+ export declare function textOf(message: Said | undefined): string;
34
+ /**
35
+ * A name from the conversation itself, with no model involved.
36
+ *
37
+ * The earliest thing said that asks for something: greetings are skipped, KONECK's own prefixes are
38
+ * skipped, and a message too short to mean anything is skipped. Falls back to the first message
39
+ * there is — because a session called "hi" is still better than one called nothing — and to a plain
40
+ * label when even that is missing.
41
+ */
42
+ export declare function mechanicalTitle(messages: readonly Said[], limit?: number): string;
43
+ /** One line, no markdown fences, no newlines, trimmed to length at a word boundary. */
44
+ export declare function tidy(text: string, limit?: number): string;
45
+ /**
46
+ * Whether a session has enough in it to be worth naming properly.
47
+ *
48
+ * Asking a model to name "hi" wastes a call and produces a title about greetings. Two exchanges, or
49
+ * one substantial request, is the point at which there is something to summarise.
50
+ */
51
+ export declare function worthNaming(messages: readonly Said[]): boolean;
52
+ /**
53
+ * What to ask the model for.
54
+ *
55
+ * The instruction is narrow on purpose: a model asked to "summarise" writes a sentence, and a model
56
+ * asked for a title writes "Session Summary: Implementation of...". Naming the shape wanted, giving
57
+ * an example of the register, and forbidding the two habits it will otherwise reach for is what gets
58
+ * a usable answer back in one attempt rather than three.
59
+ */
60
+ export declare function titleQuestion(messages: readonly Said[], take?: number): string;
61
+ /**
62
+ * The model's answer, made safe to use as a title, or null when it is not usable.
63
+ *
64
+ * A model asked for six words will sometimes send a paragraph, a quoted phrase, a bulleted list or
65
+ * an apology. Anything that does not look like a label is refused, and the mechanical name stands —
66
+ * a bad title is worse than a plain one, because a picker full of "Certainly! Here is a title" is
67
+ * unreadable in a way that "hi" at least is not.
68
+ */
69
+ export declare function usableTitle(answer: string, limit?: number): string | null;
70
+ //# sourceMappingURL=session-title.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-title.d.ts","sourceRoot":"","sources":["../src/session-title.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH;;;;;;GAMG;AACH,MAAM,WAAW,IAAI;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AA2BD,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAElD;AAED,iEAAiE;AACjE,wBAAgB,MAAM,CAAC,OAAO,EAAE,IAAI,GAAG,SAAS,GAAG,MAAM,CAWxD;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,SAAS,IAAI,EAAE,EAAE,KAAK,SAAK,GAAG,MAAM,CAW7E;AAED,uFAAuF;AACvF,wBAAgB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,SAAK,GAAG,MAAM,CAUrD;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,SAAS,IAAI,EAAE,GAAG,OAAO,CAM9D;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,SAAS,IAAI,EAAE,EAAE,IAAI,SAAI,GAAG,MAAM,CAazE;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,SAAK,GAAG,MAAM,GAAG,IAAI,CA4BrE"}
@@ -0,0 +1,167 @@
1
+ /**
2
+ * What a session is called.
3
+ *
4
+ * It was called whatever you typed first, truncated to eighty characters, for ever. So a session
5
+ * that began "hi" and went on to build a ledger page was called "hi" — and the picker, which exists
6
+ * to answer "which of these was the one about X", answered "hi", "ok", "continue" and "what is 2+2"
7
+ * for a week's work.
8
+ *
9
+ * Two mistakes in one line of code. The first message is often not the request: it is a greeting, an
10
+ * acknowledgement, a "carry on". And a title set once cannot follow a session that turned into
11
+ * something else, which is what sessions do.
12
+ *
13
+ * So the name is derived from the substance and revised as the substance changes. Mechanically
14
+ * first, because that always works and costs nothing: the earliest message that actually asks for
15
+ * something, cleaned up. Then, once there is enough to summarise, the model is asked for a short
16
+ * one — which is the only way to get "Stokvel dashboard with recharts" out of a conversation that
17
+ * happens to open with "hi". That ask is cheap, happens once, and never blocks a turn; when it
18
+ * fails the mechanical name stands.
19
+ */
20
+ /**
21
+ * Openers that say nothing about the work.
22
+ *
23
+ * Anchored, and matched against the whole message rather than its start: "hi" is a greeting and
24
+ * "highlight the failing test" is not, and a prefix match cannot tell them apart.
25
+ */
26
+ const PLEASANTRY = new RegExp('^(?:'
27
+ + [
28
+ 'hi', 'hey', 'hello', 'yo', 'sup', 'ok', 'okay', 'k',
29
+ 'thanks', 'thank you', 'ta', 'cheers',
30
+ 'cool', 'nice', 'great', 'lovely', 'perfect',
31
+ // Both halves of a greeting, because "good morning" is two words and "morning" is one, and a
32
+ // list of single words matched neither.
33
+ '(?:good )?(?:morning|afternoon|evening|day)',
34
+ '(?:hi|hey|hello) there',
35
+ 'please', 'continue', 'carry on', 'go on', 'go ahead', 'proceed',
36
+ 'yes', 'yep', 'yeah', 'no', 'nope', 'sure', 'right', 'done', 'next',
37
+ 'and\\?', 'well\\?',
38
+ ].join('|')
39
+ + ')[\\s!.,?]*$', 'i');
40
+ /** KONECK's own scaffolding, which a person did not write and must never be read as a request. */
41
+ const MACHINERY = /^\s*\[(continue|persistent session goal|effort|the request you are working on)/i;
42
+ export function isPleasantry(text) {
43
+ return PLEASANTRY.test(text.trim());
44
+ }
45
+ /** The text of a message, whatever shape its content came in. */
46
+ export function textOf(message) {
47
+ if (!message)
48
+ return '';
49
+ const content = message.content;
50
+ if (typeof content === 'string')
51
+ return content;
52
+ if (!Array.isArray(content))
53
+ return '';
54
+ return content
55
+ .filter((part) => !!part && typeof part === 'object')
56
+ .map(part => (part.type === 'text' && typeof part.text === 'string' ? part.text : ''))
57
+ .join(' ')
58
+ .trim();
59
+ }
60
+ /**
61
+ * A name from the conversation itself, with no model involved.
62
+ *
63
+ * The earliest thing said that asks for something: greetings are skipped, KONECK's own prefixes are
64
+ * skipped, and a message too short to mean anything is skipped. Falls back to the first message
65
+ * there is — because a session called "hi" is still better than one called nothing — and to a plain
66
+ * label when even that is missing.
67
+ */
68
+ export function mechanicalTitle(messages, limit = 72) {
69
+ const fromUser = messages.filter(m => m.role === 'user').map(textOf);
70
+ const substantive = fromUser.find(text => {
71
+ const trimmed = text.trim();
72
+ if (trimmed === '' || MACHINERY.test(trimmed))
73
+ return false;
74
+ if (isPleasantry(trimmed))
75
+ return false;
76
+ // Three words or twelve characters: below that there is nothing to name.
77
+ return trimmed.length >= 12 || trimmed.split(/\s+/).length >= 3;
78
+ });
79
+ const chosen = substantive ?? fromUser.find(t => t.trim() !== '') ?? '';
80
+ return tidy(chosen, limit) || 'New session';
81
+ }
82
+ /** One line, no markdown fences, no newlines, trimmed to length at a word boundary. */
83
+ export function tidy(text, limit = 72) {
84
+ const oneLine = String(text ?? '')
85
+ .replace(/```[\s\S]*?```/g, ' ')
86
+ .replace(/[`*_#>]/g, '')
87
+ .replace(/\s+/g, ' ')
88
+ .trim();
89
+ if (oneLine.length <= limit)
90
+ return oneLine;
91
+ const cut = oneLine.slice(0, limit);
92
+ const lastSpace = cut.lastIndexOf(' ');
93
+ return (lastSpace > limit * 0.6 ? cut.slice(0, lastSpace) : cut).replace(/[\s,.;:–-]+$/, '') + '…';
94
+ }
95
+ /**
96
+ * Whether a session has enough in it to be worth naming properly.
97
+ *
98
+ * Asking a model to name "hi" wastes a call and produces a title about greetings. Two exchanges, or
99
+ * one substantial request, is the point at which there is something to summarise.
100
+ */
101
+ export function worthNaming(messages) {
102
+ const fromUser = messages.filter(m => m.role === 'user').map(textOf)
103
+ .filter(t => t.trim() !== '' && !MACHINERY.test(t));
104
+ if (fromUser.length === 0)
105
+ return false;
106
+ const substantial = fromUser.some(t => !isPleasantry(t) && t.trim().length >= 20);
107
+ return substantial || fromUser.length >= 2;
108
+ }
109
+ /**
110
+ * What to ask the model for.
111
+ *
112
+ * The instruction is narrow on purpose: a model asked to "summarise" writes a sentence, and a model
113
+ * asked for a title writes "Session Summary: Implementation of...". Naming the shape wanted, giving
114
+ * an example of the register, and forbidding the two habits it will otherwise reach for is what gets
115
+ * a usable answer back in one attempt rather than three.
116
+ */
117
+ export function titleQuestion(messages, take = 8) {
118
+ const relevant = messages
119
+ .filter(m => m.role === 'user' || m.role === 'assistant')
120
+ .slice(0, take)
121
+ .map(m => `${m.role === 'user' ? 'asked' : 'did'}: ${tidy(textOf(m), 240)}`)
122
+ .filter(line => line.length > 8)
123
+ .join('\n');
124
+ return 'Name this coding session in three to six words, as a person would label a tab.\n\n'
125
+ + relevant
126
+ + '\n\nAnswer with the name alone: no quotes, no full stop, no prefix like "Session" or '
127
+ + '"Task". Name what the work is about, not that it is a session. If the conversation opens '
128
+ + 'with a greeting, ignore the greeting.';
129
+ }
130
+ /**
131
+ * The model's answer, made safe to use as a title, or null when it is not usable.
132
+ *
133
+ * A model asked for six words will sometimes send a paragraph, a quoted phrase, a bulleted list or
134
+ * an apology. Anything that does not look like a label is refused, and the mechanical name stands —
135
+ * a bad title is worse than a plain one, because a picker full of "Certainly! Here is a title" is
136
+ * unreadable in a way that "hi" at least is not.
137
+ */
138
+ export function usableTitle(answer, limit = 72) {
139
+ const first = String(answer ?? '')
140
+ .split('\n')
141
+ .map(line => line.trim())
142
+ .find(line => line !== '' && !/^[-*•\d.]+\s*$/.test(line));
143
+ if (!first)
144
+ return null;
145
+ /*
146
+ * The sentence test runs before the trim, not after.
147
+ *
148
+ * Trimming first made a paragraph look like a title: "This session covers building a ledger page
149
+ * with demo data and styling it..." is nineteen words, but cut to seventy-two characters it is
150
+ * eleven, and passed a check meant to reject exactly that.
151
+ */
152
+ const withoutPrefix = first
153
+ .replace(/^["'`]+|["'`]+$/g, '')
154
+ .replace(/^(session|task|title|chat)\s*[:\-–]\s*/i, '')
155
+ .trim();
156
+ if (withoutPrefix.split(/\s+/).length > 12)
157
+ return null;
158
+ const cleaned = tidy(withoutPrefix, limit).replace(/[.!]+$/, '').trim();
159
+ if (cleaned.length < 3)
160
+ return null;
161
+ // A refusal or a preamble is not a title, however politely it is phrased.
162
+ if (/^(certainly|sure|here is|here's|of course|i cannot|i can't|as an ai)\b/i.test(cleaned)) {
163
+ return null;
164
+ }
165
+ return cleaned;
166
+ }
167
+ //# sourceMappingURL=session-title.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-title.js","sourceRoot":"","sources":["../src/session-title.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/web/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AASH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAuB/C,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAGrC,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oFAAoF;IACpF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mCAAmC;IACnC,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AA6BD,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B;AAED,wBAAsB,QAAQ,CAC5B,YAAY,EAAE,WAAW,EAAE,OAAO,GAAE,UAAe,GAClD,OAAO,CAAC,SAAS,CAAC,CAo0CpB"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/web/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AASH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAuB/C,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAGrC,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oFAAoF;IACpF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mCAAmC;IACnC,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AA6BD,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B;AAED,wBAAsB,QAAQ,CAC5B,YAAY,EAAE,WAAW,EAAE,OAAO,GAAE,UAAe,GAClD,OAAO,CAAC,SAAS,CAAC,CA44CpB"}
package/dist/web/api.js CHANGED
@@ -131,7 +131,7 @@ export async function startWeb(baseConfigIn, options = {}) {
131
131
  if (route === '/api/workspaces' && req.method === 'GET') {
132
132
  if (!mayRead)
133
133
  return json(res, 401, { error: 'token required' });
134
- const trusted = await trustedWorkspaces().catch(() => []);
134
+ const trusted = await trustedWorkspaces(baseConfig.stateHome).catch(() => []);
135
135
  // The launch directory is always offered even if it is not in the store, because getting
136
136
  // this far means it was trusted or explicitly run with --ci.
137
137
  const choices = [...new Set([baseConfig.cwd, ...trusted])];
@@ -213,6 +213,76 @@ export async function startWeb(baseConfigIn, options = {}) {
213
213
  provider: info.provider,
214
214
  });
215
215
  }
216
+ /*
217
+ * The computer's own folder dialog, opened by the server.
218
+ *
219
+ * A browser cannot do this: webkitdirectory and showDirectoryPicker both withhold the
220
+ * absolute path deliberately. The server can, because it is on the same machine as the files
221
+ * and reaching this route already requires loopback and the acting token.
222
+ *
223
+ * Loopback is load-bearing here rather than incidental: the dialog appears on the machine
224
+ * running KONECK, so a server bound to a network address would be putting a window on
225
+ * somebody else's screen and waiting for them to answer it.
226
+ */
227
+ if (route === '/api/pickfolder' && req.method === 'POST') {
228
+ if (!mayAct)
229
+ return json(res, 401, { error: 'token required' });
230
+ if (!isLocal) {
231
+ return json(res, 400, { error: 'This server is listening on a network address, so the dialog would open on the '
232
+ + 'machine running KONECK rather than yours. Use the folder list instead.' });
233
+ }
234
+ const body = await readBody(req);
235
+ const { chooseFolder, dialogAvailable, hasDesktop } = await import('../folder-dialog.js');
236
+ if (!hasDesktop()) {
237
+ return json(res, 200, { available: false, reason: 'This machine has no desktop session, so there is no dialog to show — KONECK is '
238
+ + 'probably running over SSH. Use the folder list instead.' });
239
+ }
240
+ if (!(await dialogAvailable())) {
241
+ return json(res, 200, { available: false, reason: 'No folder dialog is installed. On most desktops that is the zenity package, or '
242
+ + 'kdialog on KDE. Use the folder list instead.' });
243
+ }
244
+ const chosen = await chooseFolder(String(body.startIn ?? '') || workspace, body.newFolder === true ? 'Choose where the new folder goes' : 'Open a project');
245
+ if (!chosen.ok) {
246
+ // Dismissing a dialog is a decision, not a failure, and is reported as one.
247
+ return json(res, 200, chosen.cancelled
248
+ ? { available: true, cancelled: true }
249
+ : { available: false, reason: chosen.reason });
250
+ }
251
+ return json(res, 200, { available: true, path: chosen.path });
252
+ }
253
+ /*
254
+ * A new folder, made where the dialog said.
255
+ *
256
+ * Its own step rather than part of the dialog, because a folder dialog chooses an existing
257
+ * folder — asking one to invent a name is asking it to be a save dialog, and the two behave
258
+ * differently on every platform.
259
+ */
260
+ if (route === '/api/newfolder' && req.method === 'POST') {
261
+ if (!mayAct)
262
+ return json(res, 401, { error: 'token required' });
263
+ if (!isLocal)
264
+ return json(res, 400, { error: 'not from a network address' });
265
+ const body = await readBody(req);
266
+ const inside = path.resolve(String(body.inside ?? '') || workspace);
267
+ const name = String(body.name ?? '').trim();
268
+ // A name, not a path: anything with a separator in it is somewhere else entirely.
269
+ if (!name || /[\\/]/.test(name) || name === '.' || name === '..') {
270
+ return json(res, 400, { error: 'give the folder a name, without slashes in it' });
271
+ }
272
+ const made = path.join(inside, name);
273
+ const { mkdir } = await import('fs/promises');
274
+ try {
275
+ await mkdir(made, { recursive: false });
276
+ }
277
+ catch (err) {
278
+ const why = err.code === 'EEXIST'
279
+ ? `${made} already exists`
280
+ : `could not create ${made}`;
281
+ return json(res, 400, { error: why });
282
+ }
283
+ announce(` Folder created from the browser: ${made}`);
284
+ return json(res, 200, { path: made });
285
+ }
216
286
  if (route === '/api/browse' && req.method === 'GET') {
217
287
  if (!mayRead)
218
288
  return json(res, 401, { error: 'token required' });
@@ -282,9 +352,9 @@ export async function startWeb(baseConfigIn, options = {}) {
282
352
  + 'through it. Switch workspace at a terminal.' });
283
353
  }
284
354
  const wasKnown = wanted === path.resolve(baseConfig.cwd)
285
- || await isWorkspaceTrusted(wanted).catch(() => false);
355
+ || await isWorkspaceTrusted(wanted, baseConfig.stateHome).catch(() => false);
286
356
  if (!wasKnown) {
287
- await trustWorkspace(wanted).catch(() => undefined);
357
+ await trustWorkspace(wanted, baseConfig.stateHome).catch(() => undefined);
288
358
  announce(` Workspace opened from the browser, and trusted: ${wanted}`);
289
359
  }
290
360
  workspace = wanted;
@@ -342,7 +412,7 @@ export async function startWeb(baseConfigIn, options = {}) {
342
412
  if (url.searchParams.get('scope') === 'workspace') {
343
413
  return json(res, 200, await searchSessions(workspace, q));
344
414
  }
345
- const trusted = await trustedWorkspaces().catch(() => []);
415
+ const trusted = await trustedWorkspaces(baseConfig.stateHome).catch(() => []);
346
416
  return json(res, 200, await searchAllWorkspaces([workspace, baseConfig.cwd, ...trusted], q));
347
417
  }
348
418
  if (route === '/api/sessions' && req.method === 'GET') {