dsh-composer-plus 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -11
- package/assets/features/contentSearch.webp +0 -0
- package/assets/features/featureIdeas.webp +0 -0
- package/client.js +175 -47
- package/cordis.patch.yml +12 -0
- package/index.js +149 -29
- package/locale/de.json +1 -1
- package/locale/en.json +1 -1
- package/locale/es.json +1 -1
- package/locale/fr.json +1 -1
- package/locale/hi.json +1 -1
- package/locale/id.json +1 -1
- package/locale/ja.json +1 -1
- package/locale/ko.json +1 -1
- package/locale/pt-BR.json +1 -1
- package/locale/ru.json +1 -1
- package/locale/tr.json +1 -1
- package/locale/vi.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +5 -3
- package/tether/gateway/server.mjs +16 -6
package/cordis.patch.yml
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
1
|
- insert:
|
|
2
2
|
- id: composer-plus
|
|
3
3
|
name: dsh-composer-plus
|
|
4
|
+
|
|
5
|
+
# Content search: dsh's own sidebar search (Cmd+K) also finds chats by the words in their messages. dsh ships the index
|
|
6
|
+
# off (`openAt: never`, `:memory:`); this later layer switches it on with a durable, owner-only file under the dsh home.
|
|
7
|
+
# The index opens at the first search, not at start. Settings → Composer Plus → Content search writes
|
|
8
|
+
# `storages/dsh-composer-plus/content-search.json`; `{ "on": false }` keeps dsh's default (titles only). Read at start only.
|
|
9
|
+
# `journalMode: delete`: a rebuildable index needs no write-ahead log; WAL kept a second file as large as the index
|
|
10
|
+
# (measured: 166 MB next to a 165 MB index) until dsh quit.
|
|
11
|
+
- id: session-query-sqlite
|
|
12
|
+
config:
|
|
13
|
+
path: !!js dshHomePath('cache', 'composer-plus', 'session-search.db')
|
|
14
|
+
openAt: !!js "(() => { try { return JSON.parse(process.getBuiltinModule('node:fs').readFileSync(dshHomePath('storages', 'dsh-composer-plus', 'content-search.json'), 'utf8'))?.on === false ? 'never' : 'first-search' } catch { return 'first-search' } })()"
|
|
15
|
+
journalMode: delete
|
package/index.js
CHANGED
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
* - Resume after restart: remembers the chats that were running when dsh quit and continues them at the next start; off by
|
|
11
11
|
* default (it starts model turns without a click).
|
|
12
12
|
* - Feature ideas: on a click, one model call (the chat's own model) reads a short brief of the chat's project and returns
|
|
13
|
-
* feature ideas as cards. Read-only: no chat message, nothing written to the project.
|
|
13
|
+
* feature ideas as cards. Read-only: no chat message, nothing written to the project. Ideas saved for later are kept
|
|
14
|
+
* per project folder under the dsh home.
|
|
14
15
|
*/
|
|
15
16
|
import { constants, readFileSync } from 'node:fs';
|
|
16
17
|
import { mkdir, open, readdir, readFile, realpath, rename, stat, writeFile } from 'node:fs/promises';
|
|
17
18
|
import { randomUUID } from 'node:crypto';
|
|
18
19
|
import { homedir } from 'node:os';
|
|
19
|
-
import { dirname, join, sep } from 'node:path';
|
|
20
|
+
import { dirname, isAbsolute, join, sep } from 'node:path';
|
|
20
21
|
import { createGateway } from './tether/gateway/server.mjs';
|
|
21
22
|
|
|
22
23
|
export const name = 'dsh-composer-plus';
|
|
@@ -31,9 +32,11 @@ export const SHOT_PATH = '/api/composer-plus.shot';
|
|
|
31
32
|
export const MOBILE_PATH = '/api/composer-plus.mobile';
|
|
32
33
|
export const RESUME_PATH = '/api/composer-plus.resume';
|
|
33
34
|
export const IDEAS_PATH = '/api/composer-plus.ideas';
|
|
35
|
+
export const LATER_PATH = '/api/composer-plus.later';
|
|
36
|
+
export const CONTENT_SEARCH_PATH = '/api/composer-plus.content-search';
|
|
34
37
|
/** Feature screenshots shipped in assets/features/<id>.webp; only these ids are served (no path input). */
|
|
35
38
|
export const SHOTS = ['changesPanel', 'featureIdeas', 'projectStatus', 'recentProjects', 'collapseProjects', 'statusOrder', 'notifications', 'allowForChat', 'continueButton', 'resumeAfterRestart', 'inputHistory', 'planToggle', 'keepFocus',
|
|
36
|
-
'queueReorder', 'mobileConnect', 'copyDeeplink', 'copyProjectPath', 'language', 'autoUpdate'];
|
|
39
|
+
'queueReorder', 'mobileConnect', 'copyDeeplink', 'copyProjectPath', 'language', 'autoUpdate', 'contentSearch'];
|
|
37
40
|
const PACKAGE = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
38
41
|
const NPM_NAME = 'dsh-composer-plus';
|
|
39
42
|
const RELEASES_URL = 'https://api.github.com/repos/furkancak1r/dsh-composer-plus/releases/latest';
|
|
@@ -204,10 +207,11 @@ export async function checkUpdate(fetchImpl = fetch, pkg = PACKAGE, signal) {
|
|
|
204
207
|
return { ...info, newer: npm.ok };
|
|
205
208
|
}
|
|
206
209
|
|
|
207
|
-
/** Mobile connect
|
|
208
|
-
|
|
210
|
+
/** A Host switch (Mobile connect, Content search): `{ on }` in an owner-only JSON file. A missing or unreadable file, or
|
|
211
|
+
* any value other than the opposite of `fallback`, reads as `fallback`. */
|
|
212
|
+
export function mobileSwitch(file, fallback = false) {
|
|
209
213
|
return {
|
|
210
|
-
read: async () => { try {
|
|
214
|
+
read: async () => { try { const on = JSON.parse(await readFile(file, 'utf8'))?.on; return fallback ? on !== false : on === true; } catch { return fallback; } },
|
|
211
215
|
write: async (on) => {
|
|
212
216
|
await mkdir(dirname(file), { recursive: true });
|
|
213
217
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
@@ -435,10 +439,27 @@ Answer with one JSON array only, with no other text. Each element is an object w
|
|
|
435
439
|
- "title": the feature name, at most 8 words.
|
|
436
440
|
- "summary": what the feature does, 1 or 2 sentences.
|
|
437
441
|
- "why": the benefit for the users, 1 sentence.
|
|
438
|
-
- "effort": one of "small", "medium", "large".
|
|
439
|
-
|
|
442
|
+
- "effort": one of "small", "medium", "large". Always write this value in English.`;
|
|
443
|
+
/** A locale id such as "tr" or "pt-BR"; anything else is ignored, so the prompt never takes free text from the browser. */
|
|
444
|
+
const LOCALE_ID = /^[a-z]{2,3}(-[a-z0-9]{2,8}){0,3}$/i;
|
|
445
|
+
/** The language line of the idea prompt: the user's UI language when the client sends one, else the brief's language. */
|
|
446
|
+
export function ideasLanguage(locale) {
|
|
447
|
+
if (typeof locale !== 'string' || !LOCALE_ID.test(locale)) return 'Write "title", "summary" and "why" in the language of the project brief.';
|
|
448
|
+
let name;
|
|
449
|
+
try { name = new Intl.DisplayNames(['en'], { type: 'language' }).of(locale); } catch { name = undefined; }
|
|
450
|
+
return `Write "title", "summary" and "why" in ${name && name !== locale ? `${name} (${locale})` : locale}, also when the project brief uses another language.`;
|
|
451
|
+
}
|
|
440
452
|
|
|
441
453
|
const clip = (value, max) => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : '');
|
|
454
|
+
/** One idea, cleaned and bounded (from the model or the browser), or undefined without a title and a summary. */
|
|
455
|
+
const ideaFrom = (item) => {
|
|
456
|
+
const title = clip(item?.title, 120);
|
|
457
|
+
const summary = clip(item?.summary, 600);
|
|
458
|
+
if (title === '' || summary === '') return undefined;
|
|
459
|
+
const why = clip(item?.why, 300);
|
|
460
|
+
const effort = ['small', 'medium', 'large'].includes(item?.effort) ? item.effort : undefined;
|
|
461
|
+
return { title, summary, ...(why === '' ? {} : { why }), ...(effort === undefined ? {} : { effort }) };
|
|
462
|
+
};
|
|
442
463
|
/** Ideas from the model's answer: the first JSON array in it (a code fence is fine), each idea cleaned and bounded. Throws
|
|
443
464
|
* when the answer holds no usable idea. */
|
|
444
465
|
export function parseIdeas(text) {
|
|
@@ -450,11 +471,9 @@ export function parseIdeas(text) {
|
|
|
450
471
|
if (!Array.isArray(list)) throw new Error('the model did not answer with a list of ideas');
|
|
451
472
|
const ideas = [];
|
|
452
473
|
for (const item of list) {
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const effort = ['small', 'medium', 'large'].includes(item?.effort) ? item.effort : undefined;
|
|
457
|
-
ideas.push({ id: randomUUID(), title, summary, ...(clip(item?.why, 300) === '' ? {} : { why: clip(item.why, 300) }), ...(effort === undefined ? {} : { effort }) });
|
|
474
|
+
const idea = ideaFrom(item);
|
|
475
|
+
if (idea === undefined) continue;
|
|
476
|
+
ideas.push({ id: randomUUID(), ...idea });
|
|
458
477
|
if (ideas.length === IDEAS_MAX) break;
|
|
459
478
|
}
|
|
460
479
|
if (ideas.length === 0) throw new Error('the model answered without a usable idea');
|
|
@@ -470,8 +489,9 @@ function ideasRoute(events, defaultModel) {
|
|
|
470
489
|
return undefined;
|
|
471
490
|
}
|
|
472
491
|
|
|
473
|
-
/** One model call for feature ideas about the chat's project
|
|
474
|
-
|
|
492
|
+
/** One model call for feature ideas about the chat's project, written in `locale` (the dsh UI language) when given.
|
|
493
|
+
* Read-only: no chat message, nothing written to the project. */
|
|
494
|
+
export async function suggestIdeas({ llm, sessionQuery, subprocess, defaultModel }, sessionId, signal, locale) {
|
|
475
495
|
const observation = await sessionQuery.observeSession(sessionId, { signal, projectionMode: 'none' });
|
|
476
496
|
let cwd, route;
|
|
477
497
|
try { cwd = observation.header?.cwd; route = ideasRoute(observation.events ?? [], defaultModel); } finally { observation[Symbol.dispose]?.(); }
|
|
@@ -489,7 +509,7 @@ export async function suggestIdeas({ llm, sessionQuery, subprocess, defaultModel
|
|
|
489
509
|
});
|
|
490
510
|
let text = '';
|
|
491
511
|
let finish;
|
|
492
|
-
for await (const chunk of call.stream({ ...call.config, system: IDEAS_SYSTEM
|
|
512
|
+
for await (const chunk of call.stream({ ...call.config, system: `${IDEAS_SYSTEM}\n${ideasLanguage(locale)}`, signal: callSignal,
|
|
493
513
|
messages: [{ role: 'user', content: [{ type: 'text', text: `Project folder name: ${cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? ''}\n\n${brief}` }] }] })) {
|
|
494
514
|
if (chunk.type === 'text-delta') text += chunk.text;
|
|
495
515
|
else if (chunk.type === 'finish') finish = chunk.reason;
|
|
@@ -505,14 +525,20 @@ export function ideaPrompt(idea) {
|
|
|
505
525
|
'First read the related code. Then plan the smallest change that delivers this feature, with the checks that prove it works.'].join('\n');
|
|
506
526
|
}
|
|
507
527
|
|
|
528
|
+
/** The chat's own project folder, from its session header (the browser never sends a path). */
|
|
529
|
+
async function chatFolder(sessionQuery, sessionId, signal) {
|
|
530
|
+
const observation = await sessionQuery.observeSession(sessionId, { signal, projectionMode: 'none' });
|
|
531
|
+
let cwd;
|
|
532
|
+
try { cwd = observation.header?.cwd; } finally { observation[Symbol.dispose]?.(); }
|
|
533
|
+
if (!isText(cwd, 4096) || !isAbsolute(cwd)) throw Object.assign(new Error('this chat has no project folder'), { status: 409 });
|
|
534
|
+
return cwd;
|
|
535
|
+
}
|
|
536
|
+
|
|
508
537
|
/** Start a new chat in the same project (the same sidebar group as the source chat) for one accepted idea, and enter plan
|
|
509
538
|
* mode with the idea as the request. When the profile has no /plan command, or /plan fails, the idea goes as an ordinary
|
|
510
539
|
* message. */
|
|
511
540
|
export async function startIdeaChat({ sessionController, sessionQuery, commands, workspaces }, sessionId, idea, signal = new AbortController().signal) {
|
|
512
|
-
const
|
|
513
|
-
let cwd;
|
|
514
|
-
try { cwd = observation.header?.cwd; } finally { observation[Symbol.dispose]?.(); }
|
|
515
|
-
if (!isText(cwd, 4096)) throw Object.assign(new Error('this chat has no project folder'), { status: 409 });
|
|
541
|
+
const cwd = await chatFolder(sessionQuery, sessionId, signal);
|
|
516
542
|
let workspace;
|
|
517
543
|
try { workspace = workspaces?.list?.().find((w) => w.sessionIds.includes(sessionId)); } catch { workspace = undefined; }
|
|
518
544
|
const { sessionId: created } = await sessionController.create(workspace === undefined ? { cwd } : { workspaceId: workspace.id });
|
|
@@ -526,6 +552,46 @@ export async function startIdeaChat({ sessionController, sessionQuery, commands,
|
|
|
526
552
|
return { sessionId: created, plan };
|
|
527
553
|
}
|
|
528
554
|
|
|
555
|
+
/** Ideas saved for later: at most this many per project. A full list refuses a new idea; it never drops an old one. */
|
|
556
|
+
export const LATER_MAX = 50;
|
|
557
|
+
/** Ideas saved for later, per project folder: `{ [cwd]: [{ id, title, summary, why?, effort?, at }] }` (newest first) in
|
|
558
|
+
* one owner-only JSON file. A missing file is an empty list. A broken file throws, so no write replaces it (no data loss).
|
|
559
|
+
* Reads and writes run in order; each write replaces the file atomically. */
|
|
560
|
+
export function laterStore(file) {
|
|
561
|
+
let queue = Promise.resolve();
|
|
562
|
+
const run = (fn) => { const done = queue.then(fn); queue = done.catch(() => {}); return done; };
|
|
563
|
+
const read = async () => {
|
|
564
|
+
let text;
|
|
565
|
+
try { text = await readFile(file, 'utf8'); } catch (error) { if (error?.code === 'ENOENT') return {}; throw error; }
|
|
566
|
+
const data = JSON.parse(text);
|
|
567
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) throw new Error('the saved ideas file is not a JSON object');
|
|
568
|
+
return data;
|
|
569
|
+
};
|
|
570
|
+
const clean = (list) => (Array.isArray(list) ? list.filter((i) => isText(i?.id) && isText(i?.title, 120) && isText(i?.summary, 600)) : []);
|
|
571
|
+
// Keys are absolute folder paths only, so a key such as `__proto__` never reaches the object.
|
|
572
|
+
const folder = (cwd) => { if (!isText(cwd, 4096) || !isAbsolute(cwd)) throw Object.assign(new Error('this chat has no project folder'), { status: 409 }); return cwd; };
|
|
573
|
+
const update = (cwd, fn) => run(async () => {
|
|
574
|
+
const data = await read();
|
|
575
|
+
const list = fn(clean(data[folder(cwd)]));
|
|
576
|
+
if (list.length === 0) delete data[cwd]; else data[cwd] = list;
|
|
577
|
+
await mkdir(dirname(file), { recursive: true });
|
|
578
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
579
|
+
await writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
580
|
+
await rename(tmp, file);
|
|
581
|
+
return list;
|
|
582
|
+
});
|
|
583
|
+
return {
|
|
584
|
+
list: (cwd) => run(async () => clean((await read())[folder(cwd)])),
|
|
585
|
+
/** Add a cleaned idea at the top; the same title (any case) is not added twice. */
|
|
586
|
+
add: (cwd, idea) => update(cwd, (list) => {
|
|
587
|
+
if (list.some((i) => i.title.toLowerCase() === idea.title.toLowerCase())) return list;
|
|
588
|
+
if (list.length >= LATER_MAX) throw Object.assign(new Error(`a project keeps at most ${LATER_MAX} ideas for later`), { status: 409 });
|
|
589
|
+
return [{ id: randomUUID(), ...idea, at: Date.now() }, ...list];
|
|
590
|
+
}),
|
|
591
|
+
remove: (cwd, id) => update(cwd, (list) => list.filter((i) => i.id !== id)),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
529
595
|
const json = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
|
|
530
596
|
const isText = (v, max = 512) => typeof v === 'string' && v.length > 0 && v.length <= max;
|
|
531
597
|
|
|
@@ -678,14 +744,41 @@ export function apply(ctx) {
|
|
|
678
744
|
},
|
|
679
745
|
});
|
|
680
746
|
installResume(ctx, home);
|
|
681
|
-
installIdeas(ctx);
|
|
747
|
+
installIdeas(ctx, home);
|
|
748
|
+
installContentSearch(ctx, home);
|
|
682
749
|
}
|
|
683
750
|
|
|
684
|
-
/**
|
|
685
|
-
*
|
|
686
|
-
|
|
751
|
+
/** Content search: dsh's own sidebar search also looks in message text. The work is dsh's (`cordis.patch.yml` opens its
|
|
752
|
+
* SQLite index when this switch is on); the Host only keeps the switch, on by default. dsh reads it at start, so
|
|
753
|
+
* `GET/POST {on}` also tells whether the running index is open (`active`; undefined when dsh does not say). */
|
|
754
|
+
function installContentSearch(ctx, home) {
|
|
755
|
+
const saved = mobileSwitch(join(home, 'storages', 'dsh-composer-plus', 'content-search.json'), true);
|
|
756
|
+
const active = () => { try { const openAt = ctx.sessionQuery?.config?.openAt; return typeof openAt === 'string' ? openAt !== 'never' : undefined; } catch { return undefined; } };
|
|
757
|
+
let queue = Promise.resolve(); // one save at a time: they share a temp file name
|
|
758
|
+
ctx.connection.fetch.register({
|
|
759
|
+
path: CONTENT_SEARCH_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
|
|
760
|
+
fetch: async (request) => {
|
|
761
|
+
if (request.method === 'POST') {
|
|
762
|
+
let body;
|
|
763
|
+
try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
|
|
764
|
+
if (typeof body?.on !== 'boolean') return json({ error: 'bad request' }, 400);
|
|
765
|
+
const done = queue.then(() => saved.write(body.on));
|
|
766
|
+
queue = done.catch(() => {});
|
|
767
|
+
try { await done; } catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
|
|
768
|
+
}
|
|
769
|
+
return json({ on: await saved.read(), active: active() });
|
|
770
|
+
},
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Feature ideas: `POST {sessionId, locale?}` asks the chat's model for ideas; `POST {sessionId, idea}` starts a chat for one idea.
|
|
775
|
+
* Ideas saved for later: `GET ?sessionId` lists the project's ideas; `POST {sessionId, add | remove | start}` changes the list
|
|
776
|
+
* (`start` starts a chat for the stored idea and removes it once the chat runs). All act only on the chat's own project
|
|
777
|
+
* folder; the browser never sends a path. */
|
|
778
|
+
function installIdeas(ctx, home) {
|
|
687
779
|
const get = (name) => { try { return ctx.get?.(name); } catch { return undefined; } };
|
|
688
780
|
const status = (error) => (Number.isInteger(error?.status) ? error.status : 502);
|
|
781
|
+
const services = () => ({ sessionController: ctx.sessionController, sessionQuery: ctx.sessionQuery, commands: get('commands'), workspaces: get('workspaceRegistry') });
|
|
689
782
|
ctx.connection.fetch.register({
|
|
690
783
|
path: IDEAS_PATH, methods: ['POST'], requestBody: 'buffered',
|
|
691
784
|
fetch: async (request) => {
|
|
@@ -693,20 +786,47 @@ function installIdeas(ctx) {
|
|
|
693
786
|
try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
|
|
694
787
|
if (!isText(body?.sessionId)) return json({ error: 'bad request' }, 400);
|
|
695
788
|
if (body.idea !== undefined) {
|
|
696
|
-
const idea =
|
|
697
|
-
if (idea
|
|
789
|
+
const idea = ideaFrom(body.idea);
|
|
790
|
+
if (idea === undefined) return json({ error: 'bad request' }, 400);
|
|
698
791
|
try {
|
|
699
|
-
return json(await startIdeaChat(
|
|
700
|
-
body.sessionId, idea, request.signal));
|
|
792
|
+
return json(await startIdeaChat(services(), body.sessionId, { title: idea.title, summary: idea.summary, why: idea.why }, request.signal));
|
|
701
793
|
} catch (error) { return json({ error: String(error?.message ?? error) }, status(error)); }
|
|
702
794
|
}
|
|
703
795
|
const llm = get('llm');
|
|
704
796
|
if (llm === undefined) return json({ error: 'this dsh profile cannot call a model' }, 409);
|
|
705
797
|
try {
|
|
706
|
-
return json(await suggestIdeas({ llm, sessionQuery: ctx.sessionQuery, subprocess: ctx.subprocess, defaultModel: get('agentDefaultModel') }, body.sessionId, request.signal));
|
|
798
|
+
return json(await suggestIdeas({ llm, sessionQuery: ctx.sessionQuery, subprocess: ctx.subprocess, defaultModel: get('agentDefaultModel') }, body.sessionId, request.signal, body.locale));
|
|
707
799
|
} catch (error) { return json({ error: String(error?.message ?? error) }, status(error)); }
|
|
708
800
|
},
|
|
709
801
|
});
|
|
802
|
+
const later = laterStore(join(home, 'storages', 'dsh-composer-plus', 'later.json'));
|
|
803
|
+
const starting = new Set(); // `${cwd}\0${id}` of the stored ideas whose chat is starting now
|
|
804
|
+
ctx.connection.fetch.register({
|
|
805
|
+
path: LATER_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
|
|
806
|
+
fetch: async (request) => {
|
|
807
|
+
let body;
|
|
808
|
+
if (request.method === 'GET') body = { sessionId: new URL(request.url).searchParams.get('sessionId') };
|
|
809
|
+
else try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
|
|
810
|
+
if (!isText(body?.sessionId)) return json({ error: 'bad request' }, 400);
|
|
811
|
+
const add = body.add === undefined ? undefined : ideaFrom(body.add);
|
|
812
|
+
const action = request.method === 'GET' ? 'list' : body.add !== undefined ? 'add' : isText(body.remove) ? 'remove' : isText(body.start) ? 'start' : undefined;
|
|
813
|
+
if (action === undefined || (action === 'add' && add === undefined)) return json({ error: 'bad request' }, 400);
|
|
814
|
+
try {
|
|
815
|
+
const cwd = await chatFolder(ctx.sessionQuery, body.sessionId, request.signal);
|
|
816
|
+
if (action === 'list') return json({ ideas: await later.list(cwd) });
|
|
817
|
+
if (action === 'add') return json({ ideas: await later.add(cwd, add) });
|
|
818
|
+
if (action === 'remove') return json({ ideas: await later.remove(cwd, body.remove) });
|
|
819
|
+
const idea = (await later.list(cwd)).find((i) => i.id === body.start);
|
|
820
|
+
const key = `${cwd}\0${body.start}`;
|
|
821
|
+
if (idea === undefined || starting.has(key)) return json({ error: 'this idea is no longer in the list' }, 409);
|
|
822
|
+
starting.add(key);
|
|
823
|
+
try {
|
|
824
|
+
const started = await startIdeaChat(services(), body.sessionId, { title: idea.title, summary: idea.summary, why: idea.why }, request.signal);
|
|
825
|
+
return json({ ...started, ideas: await later.remove(cwd, idea.id) });
|
|
826
|
+
} finally { starting.delete(key); }
|
|
827
|
+
} catch (error) { return json({ error: String(error?.message ?? error) }, Number.isInteger(error?.status) ? error.status : 500); }
|
|
828
|
+
},
|
|
829
|
+
});
|
|
710
830
|
}
|
|
711
831
|
|
|
712
832
|
/** Resume after restart: follow the top-level chats, and once dsh is ready, continue the ones its last shutdown stopped.
|
package/locale/de.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Composer Plus",
|
|
4
|
-
"description": "Änderungsansicht, Karten mit Funktionsideen, Eingabeverlauf, Shift+Tab /plan, Fortsetzen mit einem Klick, Fortsetzen nach Neustart, Warteschlange per Drag-and-drop, Links, Benachrichtigungen, iPhone-Zugriff (Tether), 11 Sprachen und automatische Updates."
|
|
4
|
+
"description": "Änderungsansicht, Karten mit Funktionsideen, Suche in Nachrichten, Eingabeverlauf, Shift+Tab /plan, Fortsetzen mit einem Klick, Fortsetzen nach Neustart, Warteschlange per Drag-and-drop, Links, Benachrichtigungen, iPhone-Zugriff (Tether), 11 Sprachen und automatische Updates."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Composer Plus",
|
|
4
|
-
"description": "Changes panel, feature idea cards, input history, Shift+Tab /plan, one-click Continue, resume after restart, queue drag-and-drop, deeplinks, notifications, iPhone access (Tether), 11 UI languages and automatic updates."
|
|
4
|
+
"description": "Changes panel, feature idea cards, search in messages, input history, Shift+Tab /plan, one-click Continue, resume after restart, queue drag-and-drop, deeplinks, notifications, iPhone access (Tether), 11 UI languages and automatic updates."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/es.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Composer Plus",
|
|
4
|
-
"description": "Panel de cambios, tarjetas de ideas de funciones, historial de entrada, Shift+Tab /plan, Continuar con un clic, reanudar tras reiniciar, reordenar la cola arrastrando, enlaces, notificaciones, acceso desde iPhone (Tether), 11 idiomas y actualizaciones automáticas."
|
|
4
|
+
"description": "Panel de cambios, tarjetas de ideas de funciones, búsqueda en los mensajes, historial de entrada, Shift+Tab /plan, Continuar con un clic, reanudar tras reiniciar, reordenar la cola arrastrando, enlaces, notificaciones, acceso desde iPhone (Tether), 11 idiomas y actualizaciones automáticas."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/fr.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Composer Plus",
|
|
4
|
-
"description": "Panneau des modifications, cartes d’idées de fonctionnalités, historique de saisie, Shift+Tab /plan, Continuer en un clic, reprise après redémarrage, file réorganisable par glisser-déposer, liens, notifications, accès depuis l’iPhone (Tether), 11 langues et mises à jour automatiques."
|
|
4
|
+
"description": "Panneau des modifications, cartes d’idées de fonctionnalités, recherche dans les messages, historique de saisie, Shift+Tab /plan, Continuer en un clic, reprise après redémarrage, file réorganisable par glisser-déposer, liens, notifications, accès depuis l’iPhone (Tether), 11 langues et mises à jour automatiques."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/hi.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Composer Plus",
|
|
4
|
-
"description": "बदलाव पैनल, फ़ीचर आइडिया कार्ड, इनपुट इतिहास, Shift+Tab /plan, एक क्लिक में जारी रखें, रीस्टार्ट के बाद जारी रखें, कतार ड्रैग-एंड-ड्रॉप, लिंक, सूचनाएं, iPhone से एक्सेस (Tether), 11 भाषाएं और अपने-आप अपडेट।"
|
|
4
|
+
"description": "बदलाव पैनल, फ़ीचर आइडिया कार्ड, संदेशों में खोज, इनपुट इतिहास, Shift+Tab /plan, एक क्लिक में जारी रखें, रीस्टार्ट के बाद जारी रखें, कतार ड्रैग-एंड-ड्रॉप, लिंक, सूचनाएं, iPhone से एक्सेस (Tether), 11 भाषाएं और अपने-आप अपडेट।"
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/id.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Composer Plus",
|
|
4
|
-
"description": "Panel perubahan, kartu ide fitur, riwayat input, Shift+Tab /plan, Lanjutkan sekali klik, lanjutkan setelah mulai ulang, urutkan antrean dengan seret, tautan, notifikasi, akses dari iPhone (Tether), 11 bahasa, dan pembaruan otomatis."
|
|
4
|
+
"description": "Panel perubahan, kartu ide fitur, pencarian isi pesan, riwayat input, Shift+Tab /plan, Lanjutkan sekali klik, lanjutkan setelah mulai ulang, urutkan antrean dengan seret, tautan, notifikasi, akses dari iPhone (Tether), 11 bahasa, dan pembaruan otomatis."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/ja.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Composer Plus",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "変更パネル、機能アイデアのカード、メッセージ内容の検索、入力履歴、Shift+Tab で /plan、ワンクリック続行、再起動後の再開、キューのドラッグ並べ替え、リンク、通知、iPhone からの操作(Tether)、11 言語、自動アップデート。"
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/ko.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Composer Plus",
|
|
4
|
-
"description": "변경 사항 패널, 기능 아이디어 카드, 입력 기록, Shift+Tab /plan, 원클릭 계속, 재시작 후 이어서 실행, 대기열 끌어서 정렬, 링크, 알림, iPhone 연결(Tether), 11개 언어와 자동 업데이트."
|
|
4
|
+
"description": "변경 사항 패널, 기능 아이디어 카드, 메시지 내용 검색, 입력 기록, Shift+Tab /plan, 원클릭 계속, 재시작 후 이어서 실행, 대기열 끌어서 정렬, 링크, 알림, iPhone 연결(Tether), 11개 언어와 자동 업데이트."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/pt-BR.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Composer Plus",
|
|
4
|
-
"description": "Painel de alterações, cartões de ideias de recursos, histórico de entrada, Shift+Tab /plan, Continuar com um clique, retomar após reiniciar, reordenar a fila arrastando, links, notificações, acesso pelo iPhone (Tether), 11 idiomas e atualizações automáticas."
|
|
4
|
+
"description": "Painel de alterações, cartões de ideias de recursos, pesquisa nas mensagens, histórico de entrada, Shift+Tab /plan, Continuar com um clique, retomar após reiniciar, reordenar a fila arrastando, links, notificações, acesso pelo iPhone (Tether), 11 idiomas e atualizações automáticas."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/ru.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Composer Plus",
|
|
4
|
-
"description": "Панель изменений, карточки идей функций, история ввода, Shift+Tab /plan, продолжение в один клик, продолжение после перезапуска, перетаскивание очереди, ссылки, уведомления, доступ с iPhone (Tether), 11 языков и автообновления."
|
|
4
|
+
"description": "Панель изменений, карточки идей функций, поиск по сообщениям, история ввода, Shift+Tab /plan, продолжение в один клик, продолжение после перезапуска, перетаскивание очереди, ссылки, уведомления, доступ с iPhone (Tether), 11 языков и автообновления."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/tr.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Composer Plus",
|
|
4
|
-
"description": "Değişiklikler paneli, özellik fikri kartları, giriş geçmişi, Shift+Tab /plan, tek tıkla devam, yeniden açılışta sürdürme, sıradaki mesajları sürükle-bırak, bağlantılar, bildirimler, iPhone’dan erişim (Tether), 11 arayüz dili ve otomatik güncellemeler."
|
|
4
|
+
"description": "Değişiklikler paneli, özellik fikri kartları, mesaj içeriğinde arama, giriş geçmişi, Shift+Tab /plan, tek tıkla devam, yeniden açılışta sürdürme, sıradaki mesajları sürükle-bırak, bağlantılar, bildirimler, iPhone’dan erişim (Tether), 11 arayüz dili ve otomatik güncellemeler."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/vi.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Composer Plus",
|
|
4
|
-
"description": "Bảng thay đổi, thẻ ý tưởng tính năng, lịch sử nhập, Shift+Tab /plan, Tiếp tục một chạm, tiếp tục sau khi khởi động lại, kéo thả hàng đợi, liên kết, thông báo, truy cập từ iPhone (Tether), 11 ngôn ngữ và tự động cập nhật."
|
|
4
|
+
"description": "Bảng thay đổi, thẻ ý tưởng tính năng, tìm trong nội dung tin nhắn, lịch sử nhập, Shift+Tab /plan, Tiếp tục một chạm, tiếp tục sau khi khởi động lại, kéo thả hàng đợi, liên kết, thông báo, truy cập từ iPhone (Tether), 11 ngôn ngữ và tự động cập nhật."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Composer Plus",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "改动面板、功能创意卡片、消息内容搜索、输入历史、Shift+Tab /plan、一键继续、重启后继续、队列拖拽排序、会话链接、通知、iPhone 远程使用(Tether)、11 种界面语言和自动更新。"
|
|
5
5
|
}
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-composer-plus",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "DeepSeek Harness helpers: 11 extra UI languages (Türkçe, Español, Français, Deutsch, 日本語…), Changes panel, swipe-card feature ideas, input history, Shift+Tab /plan, one-click Continue, resume running chats after a restart, queue drag-and-drop, deeplinks, Auto Review allow-for-chat, notifications, iPhone access with the Tether app, automatic updates",
|
|
3
|
+
"version": "1.8.0",
|
|
4
|
+
"description": "DeepSeek Harness helpers: 11 extra UI languages (Türkçe, Español, Français, Deutsch, 日本語…), Changes panel, swipe-card feature ideas, sidebar search in message text, input history, Shift+Tab /plan, one-click Continue, resume running chats after a restart, queue drag-and-drop, deeplinks, Auto Review allow-for-chat, notifications, iPhone access with the Tether app, automatic updates",
|
|
5
5
|
"icon": "./assets/icon.svg",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"dsh-plugin",
|
|
26
26
|
"dsh-plugins",
|
|
27
27
|
"feature-ideas",
|
|
28
|
+
"full-text-search",
|
|
28
29
|
"git",
|
|
29
30
|
"git-diff",
|
|
30
31
|
"i18n",
|
|
@@ -41,6 +42,7 @@
|
|
|
41
42
|
"remote-control",
|
|
42
43
|
"restart",
|
|
43
44
|
"resume",
|
|
45
|
+
"search",
|
|
44
46
|
"sort-by-status",
|
|
45
47
|
"tailscale",
|
|
46
48
|
"tether",
|
|
@@ -67,7 +69,7 @@
|
|
|
67
69
|
"assets/features/*.webp"
|
|
68
70
|
],
|
|
69
71
|
"scripts": {
|
|
70
|
-
"test": "node scripts/build-locales.mjs --check && node test/locale.test.mjs && node test/continue.test.mjs && node test/continue-host.test.mjs && node test/resume-restart.test.mjs && node test/queue-drag.test.mjs && node test/plan-toggle.test.mjs && node test/keep-focus.test.mjs && node test/mobile-connect.test.mjs && node test/tether-gateway.test.mjs && node test/deeplink.test.mjs && node test/chat-grant.test.mjs && node test/notify.test.mjs && node test/changes.test.mjs && node test/feature-ideas.test.mjs && node test/feature-ideas-client.test.mjs && node test/project-status.test.mjs && node test/recent-projects.test.mjs && node test/project-tools.test.mjs && node test/status-order.test.mjs && node test/toggles.test.mjs && node test/update.test.mjs && node test/meta.test.mjs && node test/shots.test.mjs",
|
|
72
|
+
"test": "node scripts/build-locales.mjs --check && node test/locale.test.mjs && node test/continue.test.mjs && node test/continue-host.test.mjs && node test/resume-restart.test.mjs && node test/content-search.test.mjs && node test/queue-drag.test.mjs && node test/plan-toggle.test.mjs && node test/keep-focus.test.mjs && node test/mobile-connect.test.mjs && node test/tether-gateway.test.mjs && node test/deeplink.test.mjs && node test/chat-grant.test.mjs && node test/notify.test.mjs && node test/changes.test.mjs && node test/feature-ideas.test.mjs && node test/feature-ideas-client.test.mjs && node test/project-status.test.mjs && node test/recent-projects.test.mjs && node test/project-tools.test.mjs && node test/status-order.test.mjs && node test/toggles.test.mjs && node test/update.test.mjs && node test/meta.test.mjs && node test/shots.test.mjs",
|
|
71
73
|
"build:locales": "node scripts/build-locales.mjs",
|
|
72
74
|
"prepublishOnly": "npm test"
|
|
73
75
|
},
|
|
@@ -6,7 +6,7 @@ import { createServer as createHttp, request as httpRequest } from 'node:http'
|
|
|
6
6
|
import { createHash, randomBytes, randomUUID, timingSafeEqual, X509Certificate } from 'node:crypto'
|
|
7
7
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
8
8
|
import { execFile, execFileSync, spawn } from 'node:child_process'
|
|
9
|
-
import { homedir, networkInterfaces } from 'node:os'
|
|
9
|
+
import { homedir, hostname, networkInterfaces } from 'node:os'
|
|
10
10
|
import { dirname, join } from 'node:path'
|
|
11
11
|
import { fileURLToPath } from 'node:url'
|
|
12
12
|
|
|
@@ -14,6 +14,14 @@ const HERE = dirname(fileURLToPath(import.meta.url))
|
|
|
14
14
|
const TOKEN_TTL = 30 * 864e5
|
|
15
15
|
const MAX_BODY = 16 << 20
|
|
16
16
|
|
|
17
|
+
/** The name the phone shows for this Mac in its Mac picker: the Mac's own "Computer Name", else the host name. */
|
|
18
|
+
export function macName(read = () => execFileSync('scutil', ['--get', 'ComputerName'], { encoding: 'utf8', timeout: 2000 })) {
|
|
19
|
+
let n = ''
|
|
20
|
+
try { n = String(read()).trim() } catch {}
|
|
21
|
+
if (!n) n = hostname().replace(/\.local$/i, '')
|
|
22
|
+
return n.replace(/[\u0000-\u001f]/g, '').slice(0, 64) || 'Mac'
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
// Allowlisted DSH remote methods. Everything else is 403.
|
|
18
26
|
export const RPC = new Set([
|
|
19
27
|
'session/list', 'session/page', 'session/create', 'session/rename', 'session/prompt', 'session/cancel',
|
|
@@ -220,6 +228,7 @@ export function createGateway(opts = {}) {
|
|
|
220
228
|
const a = (networkInterfaces()[iface] ?? []).find(i => i.family === 'IPv4' && !i.internal)
|
|
221
229
|
return a && { address: a.address, netmask: a.netmask }
|
|
222
230
|
})
|
|
231
|
+
const mac = opts.macName ?? macName()
|
|
223
232
|
const codeTtl = opts.codeTtlMs ?? 5 * 60e3
|
|
224
233
|
const waterfallTtl = opts.waterfallTimeoutMs ?? 10 * 60e3
|
|
225
234
|
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
@@ -290,7 +299,7 @@ export function createGateway(opts = {}) {
|
|
|
290
299
|
function newCode() {
|
|
291
300
|
const net = bound ?? netInfo()
|
|
292
301
|
code = { value: randomBytes(16).toString('base64url'), expires: Date.now() + codeTtl }
|
|
293
|
-
code.payload = JSON.stringify({ v: 1, host: net?.address, port, fp, code: code.value, ...(ts.online && ts.ip ? { ts: ts.ip } : {}) })
|
|
302
|
+
code.payload = JSON.stringify({ v: 1, host: net?.address, port, fp, code: code.value, mac, ...(ts.online && ts.ip ? { ts: ts.ip } : {}) })
|
|
294
303
|
qrReady = false
|
|
295
304
|
if (opts.qr !== false) {
|
|
296
305
|
const c = code
|
|
@@ -320,7 +329,7 @@ export function createGateway(opts = {}) {
|
|
|
320
329
|
const token = randomBytes(32).toString('base64url') // 256-bit; only its hash is stored
|
|
321
330
|
const d = { id: randomUUID(), name: p.name, tokenHash: sha(token), createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + TOKEN_TTL).toISOString() }
|
|
322
331
|
devices.push(d); saveDevices()
|
|
323
|
-
reply(p.res, 200, { token, deviceId: d.id, name: d.name, expiresAt: d.expiresAt })
|
|
332
|
+
reply(p.res, 200, { token, deviceId: d.id, name: d.name, expiresAt: d.expiresAt, mac })
|
|
324
333
|
return true
|
|
325
334
|
}
|
|
326
335
|
|
|
@@ -395,7 +404,7 @@ export function createGateway(opts = {}) {
|
|
|
395
404
|
const dev = auth(req)
|
|
396
405
|
if (!dev) { fail(ip); return reply(res, 401, { error: 'unauthorized' }) }
|
|
397
406
|
track(dev.id, req.socket)
|
|
398
|
-
if (req.method === 'GET' && path === '/me') return reply(res, 200, { deviceId: dev.id, name: dev.name, expiresAt: dev.expiresAt, upstream: upState, tailscale: ts.online ? ts.ip : null })
|
|
407
|
+
if (req.method === 'GET' && path === '/me') return reply(res, 200, { deviceId: dev.id, name: dev.name, expiresAt: dev.expiresAt, upstream: upState, tailscale: ts.online ? ts.ip : null, mac })
|
|
399
408
|
if (req.method !== 'POST' || !path.startsWith('/api/')) return reply(res, 404, { error: 'not found' })
|
|
400
409
|
const method = path.slice(5)
|
|
401
410
|
let body
|
|
@@ -475,7 +484,8 @@ export function createGateway(opts = {}) {
|
|
|
475
484
|
await new Promise((ok, bad) => { s.once('error', bad); s.listen(port, net.bind ?? '::', ok) })
|
|
476
485
|
server = s; bound = net; port = s.address().port; running = true
|
|
477
486
|
if (opts.bonjour !== false) {
|
|
478
|
-
|
|
487
|
+
// The service name is the Mac's name, so the phone's "Macs on this network" list tells two Macs apart.
|
|
488
|
+
bonjour = spawn('dns-sd', ['-R', mac, '_dshmobile._tcp', 'local', String(port), 'fp=' + fp], { stdio: 'ignore' })
|
|
479
489
|
bonjour.on('error', () => {})
|
|
480
490
|
}
|
|
481
491
|
connectEvents()
|
|
@@ -505,7 +515,7 @@ export function createGateway(opts = {}) {
|
|
|
505
515
|
watch.unref()
|
|
506
516
|
|
|
507
517
|
const state = () => ({
|
|
508
|
-
running, address: bound?.address ?? netInfo()?.address ?? null, port, tls: running ? 'TLS 1.2+, self-signed' : 'off',
|
|
518
|
+
mac, running, address: bound?.address ?? netInfo()?.address ?? null, port, tls: running ? 'TLS 1.2+, self-signed' : 'off',
|
|
509
519
|
fingerprint: fp ?? null, upstream: upState, tailscale: ts, lastTlsError,
|
|
510
520
|
code: code && Date.now() < code.expires ? { payload: code.payload, expiresAt: new Date(code.expires).toISOString(), qr: qrReady } : null,
|
|
511
521
|
pending: [...pending.values()].map(({ id, name, ip, at }) => ({ id, name, ip, at })),
|