dsh-tg-bridge 1.4.1 → 1.5.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 +23 -0
- package/bot-menu.mjs +6 -0
- package/bridge.mjs +23 -1
- package/control.mjs +86 -1
- package/goal.mjs +83 -0
- package/goals.mjs +168 -0
- package/i18n.mjs +48 -3
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ blocks.
|
|
|
13
13
|
- **Voice**: local faster-whisper, the transcript is shown before the answer.
|
|
14
14
|
- **Photos**: sent to a vision-capable model; text and UI are recognized.
|
|
15
15
|
- **Project per topic**: `/cd my-app`, and all turns run from that directory.
|
|
16
|
+
- **Goal and checklist per topic**: `/goal`, `/task`; both survive restarts and are prepended to every turn.
|
|
16
17
|
- **Live progress**: "⏳ Working..." with steps, a timer and the cache hit rate at the end.
|
|
17
18
|
- **No lost messages**: a journal with acknowledgement and replay.
|
|
18
19
|
- **Fail-fast**: when the quota runs out it switches to a free model right away.
|
|
@@ -157,6 +158,7 @@ Send the bot:
|
|
|
157
158
|
|
|
158
159
|
```
|
|
159
160
|
Work: /cd · /new · /stop · /send · /seed
|
|
161
|
+
Goals: /goal · /task
|
|
160
162
|
Sessions: /status · /sessions · /resume
|
|
161
163
|
Models: /models · /model · /prices
|
|
162
164
|
Extensions: /skills · /plugins · /plugin · /soul
|
|
@@ -204,6 +206,27 @@ and a line in `~/.dsh/profiles/headless/cordis.patch.yml`:
|
|
|
204
206
|
The bridge checks the profile itself: if the line is missing, it does not write the task
|
|
205
207
|
(it would just expire) and behaves as before, queueing the message.
|
|
206
208
|
|
|
209
|
+
### Goals and tasks
|
|
210
|
+
|
|
211
|
+
A topic carries its own goal and checklist, so the aim survives `/new`, a
|
|
212
|
+
restart and a different session:
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
/goal ship the release set or replace the goal
|
|
216
|
+
/goal show the goal and the checklist
|
|
217
|
+
/task write the migration add a task
|
|
218
|
+
/task done 2 tick it off
|
|
219
|
+
/task rm 2 remove it
|
|
220
|
+
/goal done close the goal (all tasks done)
|
|
221
|
+
/goal clear delete the goal and its tasks
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The bridge prepends the goal and the list to every turn, with the exact command
|
|
225
|
+
that updates it (`goal.mjs`). The agent ticks tasks as it finishes them, and the
|
|
226
|
+
next turn sees the new state. The bridge is the only writer of `state.json`: the
|
|
227
|
+
agent drops a job file into `goal-jobs/` and the bridge applies it, so there is
|
|
228
|
+
no lock race and no lost update.
|
|
229
|
+
|
|
207
230
|
## Security
|
|
208
231
|
|
|
209
232
|
- `config.json`, `state.json`, `pending/`, logs are in `.gitignore` and never
|
package/bot-menu.mjs
CHANGED
|
@@ -36,6 +36,8 @@ const COMMANDS = [
|
|
|
36
36
|
{ command: 'prices', description: 'Цены моделей и что стоит текущая' },
|
|
37
37
|
{ command: 'send', description: 'Прислать файл из проекта в чат' },
|
|
38
38
|
{ command: 'seed', description: 'Засеять пустую тему текстом и закрепить' },
|
|
39
|
+
{ command: 'goal', description: 'Цель темы: /goal <текст>, /goal done' },
|
|
40
|
+
{ command: 'task', description: 'Задачи: /task <текст>, /task done <n>' },
|
|
39
41
|
{ command: 'sessions', description: 'Последние сессии' },
|
|
40
42
|
{ command: 'resume', description: 'Продолжить сессию по id' },
|
|
41
43
|
{ command: 'models', description: 'Список моделей' },
|
|
@@ -60,6 +62,8 @@ const COMMANDS_EN = [
|
|
|
60
62
|
{ command: 'prices', description: 'Model prices and the cost of the current one' },
|
|
61
63
|
{ command: 'send', description: 'Send a file from the project into the chat' },
|
|
62
64
|
{ command: 'seed', description: 'Seed an empty topic with text and pin it' },
|
|
65
|
+
{ command: 'goal', description: 'Topic goal: /goal <text>, /goal done' },
|
|
66
|
+
{ command: 'task', description: 'Checklist: /task <text>, /task done <n>' },
|
|
63
67
|
{ command: 'sessions', description: 'Recent sessions' },
|
|
64
68
|
{ command: 'resume', description: 'Continue a session by id' },
|
|
65
69
|
{ command: 'models', description: 'Available models' },
|
|
@@ -84,6 +88,8 @@ const COMMANDS_ZH = [
|
|
|
84
88
|
{ command: 'prices', description: '模型价格与当前模型花费' },
|
|
85
89
|
{ command: 'send', description: '把项目中的文件发送到聊天' },
|
|
86
90
|
{ command: 'seed', description: '用文本初始化空话题并置顶' },
|
|
91
|
+
{ command: 'goal', description: '本话题目标:/goal <文本>、/goal done' },
|
|
92
|
+
{ command: 'task', description: '任务清单:/task <文本>、/task done <n>' },
|
|
87
93
|
{ command: 'sessions', description: '最近的会话' },
|
|
88
94
|
{ command: 'resume', description: '按 id 继续会话' },
|
|
89
95
|
{ command: 'models', description: '可用模型' },
|
package/bridge.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
readdirSync, statSync, openSync, fsyncSync, closeSync, renameSync, writeFileSync as writeFd,
|
|
12
12
|
} from 'node:fs';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
|
-
import { join, basename, isAbsolute } from 'node:path';
|
|
14
|
+
import { join, basename, isAbsolute, dirname } from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
// Force IPv4 for every fetch. DNS returns an AAAA next to the A record, and
|
|
17
17
|
// Happy Eyeballs happily picks the IPv6 path, which here costs ~5 s or dies
|
|
@@ -26,6 +26,7 @@ import { noticeIsObsolete } from './queue-notice.mjs';
|
|
|
26
26
|
import { immediateKind } from './immediate.mjs';
|
|
27
27
|
import { writeSteerJob } from './steer-queue.mjs';
|
|
28
28
|
import { handleControl, CONTROL_COMMANDS, modelOf } from './control.mjs';
|
|
29
|
+
import { drainGoalJobs, goalOf, renderPromptBlock } from './goals.mjs';
|
|
29
30
|
import { renderPrices } from './prices.mjs';
|
|
30
31
|
import { t, resolveLang } from './i18n.mjs';
|
|
31
32
|
|
|
@@ -34,6 +35,10 @@ const DSH_HOME = process.env.DSH_HOME || join(HOME, '.dsh');
|
|
|
34
35
|
// State, config and logs live here. DSH_TG_HOME lets a checkout run with its own
|
|
35
36
|
// directory instead of the default ~/.dsh/tg-bridge.
|
|
36
37
|
const DIR = process.env.DSH_TG_HOME || join(DSH_HOME, 'tg-bridge');
|
|
38
|
+
// Code directory can differ from DIR when the bridge is started by the DSH
|
|
39
|
+
// plugin (code lives in the profile's node_modules, state stays in DIR).
|
|
40
|
+
const SELF_DIR = dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
const GOAL_CMD = join(SELF_DIR, 'goal.mjs');
|
|
37
42
|
const STATE = join(DIR, 'state.json');
|
|
38
43
|
const LOG = join(DIR, 'bridge.log');
|
|
39
44
|
const CRED = join(DSH_HOME, '.credentials.yaml');
|
|
@@ -140,6 +145,18 @@ function writeAtomic(path, text) {
|
|
|
140
145
|
function saveState() {
|
|
141
146
|
writeAtomic(STATE, JSON.stringify(state, null, 2));
|
|
142
147
|
}
|
|
148
|
+
|
|
149
|
+
// The agent updates the topic goal list by dropping a job file (goal.mjs);
|
|
150
|
+
// the bridge is the only writer of state.json, so there is no lock race.
|
|
151
|
+
const GOAL_DIR = join(DIR, 'goal-jobs');
|
|
152
|
+
setInterval(() => {
|
|
153
|
+
try {
|
|
154
|
+
if (drainGoalJobs(state, GOAL_DIR, join(GOAL_DIR, 'done'))) saveState();
|
|
155
|
+
} catch (error) {
|
|
156
|
+
log(`goal job failed: ${error.message}`);
|
|
157
|
+
}
|
|
158
|
+
}, 2000).unref();
|
|
159
|
+
|
|
143
160
|
function log(line) {
|
|
144
161
|
const text = `${new Date().toISOString()} ${line}\n`;
|
|
145
162
|
try { appendFileSync(LOG, text, 'utf8'); } catch {}
|
|
@@ -1230,6 +1247,11 @@ ${t(lang, 'help.body')}`,
|
|
|
1230
1247
|
// Project directory bound to this topic (set with /cd); config.cwd otherwise.
|
|
1231
1248
|
const cwd = (state.cwd && state.cwd[key]) || config.cwd;
|
|
1232
1249
|
|
|
1250
|
+
// Desktop-style goal: the topic's goal and task list travel with every turn,
|
|
1251
|
+
// so a fresh session after /new still knows what the project is aiming at.
|
|
1252
|
+
const topicGoal = goalOf(state, key);
|
|
1253
|
+
if (topicGoal) text = `${renderPromptBlock(topicGoal, key, GOAL_CMD)}\n\n---\n\n${text}`;
|
|
1254
|
+
|
|
1233
1255
|
// Live progress: one status message per turn, rewritten as the agent works.
|
|
1234
1256
|
// Without it a long turn and a hung turn looked exactly alike in the chat.
|
|
1235
1257
|
const progress = await startProgress(chatId, threadId, lang);
|
package/control.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { homedir } from 'node:os';
|
|
|
10
10
|
import { join, basename } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { t, LANGS, normalizeLang } from './i18n.mjs';
|
|
13
|
+
import { addTask, clearGoal, doneTask, finishGoal, goalOf, progressOf, removeTask, setGoal } from './goals.mjs';
|
|
13
14
|
|
|
14
15
|
const HOME = homedir();
|
|
15
16
|
const DSH_HOME = process.env.DSH_HOME || join(HOME, '.dsh');
|
|
@@ -346,6 +347,20 @@ function setSoul(text) {
|
|
|
346
347
|
|
|
347
348
|
const fmt = (rows) => rows.map((row) => ` ${row}`).join('\n');
|
|
348
349
|
|
|
350
|
+
/** Goal + task list as one message; empty list is shown, not hidden. */
|
|
351
|
+
function goalView(goal, lang) {
|
|
352
|
+
const { done, total } = progressOf(goal);
|
|
353
|
+
const lines = [t(lang, 'goal.set', { text: goal.text })];
|
|
354
|
+
if (goal.tasks.length === 0) {
|
|
355
|
+
lines.push(t(lang, 'task.empty'));
|
|
356
|
+
} else {
|
|
357
|
+
goal.tasks.forEach((task, i) => lines.push(`${task.done ? '✅' : '⬜'} ${i + 1}. ${task.text}`));
|
|
358
|
+
lines.push(t(lang, 'goal.progress', { done, total }));
|
|
359
|
+
}
|
|
360
|
+
if (goal.done) lines.push(t(lang, 'goal.closed'));
|
|
361
|
+
return lines.join('\n');
|
|
362
|
+
}
|
|
363
|
+
|
|
349
364
|
/**
|
|
350
365
|
* Handle one control command. Returns true when the message was a control
|
|
351
366
|
* command and the caller must not run a turn.
|
|
@@ -353,7 +368,7 @@ const fmt = (rows) => rows.map((row) => ` ${row}`).join('\n');
|
|
|
353
368
|
export const CONTROL_COMMANDS = [
|
|
354
369
|
'status', 'models', 'model', 'sessions', 'resume', 'plugins', 'plugin',
|
|
355
370
|
'skills', 'soul', 'restart', 'app', 'stop', 'new', 'seed', 'send', 'cd',
|
|
356
|
-
'prices', 'help', 'doctor', 'logs', 'lang',
|
|
371
|
+
'prices', 'help', 'doctor', 'logs', 'lang', 'goal', 'task',
|
|
357
372
|
];
|
|
358
373
|
|
|
359
374
|
export async function handleControl(ctx) {
|
|
@@ -394,6 +409,13 @@ export async function handleControl(ctx) {
|
|
|
394
409
|
const deskPlugins = pluginsOf('desktop');
|
|
395
410
|
lines.splice(1, 0, `UI: desktop (${desk.provider}/${desk.model}) — плагинов ${deskPlugins.bundles.length}`);
|
|
396
411
|
}
|
|
412
|
+
const currentGoal = goalOf(state, key);
|
|
413
|
+
if (currentGoal) {
|
|
414
|
+
const { done, total } = progressOf(currentGoal);
|
|
415
|
+
lines.push(t(lang || 'ru', 'status.goal', {
|
|
416
|
+
done, total, text: currentGoal.text.length > 60 ? `${currentGoal.text.slice(0, 59)}…` : currentGoal.text,
|
|
417
|
+
}));
|
|
418
|
+
}
|
|
397
419
|
say(lines.join('\n'));
|
|
398
420
|
return true;
|
|
399
421
|
}
|
|
@@ -689,6 +711,69 @@ export async function handleControl(ctx) {
|
|
|
689
711
|
return true;
|
|
690
712
|
}
|
|
691
713
|
|
|
714
|
+
case '/goal': {
|
|
715
|
+
const L = lang || 'ru';
|
|
716
|
+
const body = (arg || '').trim();
|
|
717
|
+
const current = goalOf(state, key);
|
|
718
|
+
if (!body) {
|
|
719
|
+
say(current ? goalView(current, L) : t(L, 'goal.none'));
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
const word = body.toLowerCase();
|
|
723
|
+
if (word === 'help' || word === '?') {
|
|
724
|
+
say(t(L, 'goal.usage'));
|
|
725
|
+
return true;
|
|
726
|
+
}
|
|
727
|
+
if (word === 'done') {
|
|
728
|
+
const goal = finishGoal(state, key);
|
|
729
|
+
if (!goal) { say(t(L, 'goal.none')); return true; }
|
|
730
|
+
ctx.saveState();
|
|
731
|
+
say(goalView(goal, L));
|
|
732
|
+
return true;
|
|
733
|
+
}
|
|
734
|
+
if (word === 'clear') {
|
|
735
|
+
say(t(L, clearGoal(state, key) ? 'goal.cleared' : 'goal.none'));
|
|
736
|
+
if (goalOf(state, key) === null) ctx.saveState();
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
const goal = setGoal(state, key, body);
|
|
740
|
+
if (!goal) { say(t(L, 'goal.usage')); return true; }
|
|
741
|
+
ctx.saveState();
|
|
742
|
+
say(goalView(goal, L));
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
case '/task': {
|
|
747
|
+
const L = lang || 'ru';
|
|
748
|
+
const body = (arg || '').trim();
|
|
749
|
+
const current = goalOf(state, key);
|
|
750
|
+
if (!body) {
|
|
751
|
+
say(current ? goalView(current, L) : t(L, 'task.none'));
|
|
752
|
+
return true;
|
|
753
|
+
}
|
|
754
|
+
if (body.toLowerCase() === 'help' || body.toLowerCase() === '?') {
|
|
755
|
+
say(t(L, 'task.usage'));
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
const op = body.match(/^(done|rm|remove)\s+(\d+)$/i);
|
|
759
|
+
if (op) {
|
|
760
|
+
const n = Number(op[2]);
|
|
761
|
+
if (op[1].toLowerCase() === 'done') {
|
|
762
|
+
say(t(L, doneTask(state, key, n) ? 'task.done' : 'task.bad', { n }));
|
|
763
|
+
} else {
|
|
764
|
+
say(t(L, removeTask(state, key, n) ? 'task.removed' : 'task.bad', { n }));
|
|
765
|
+
}
|
|
766
|
+
ctx.saveState();
|
|
767
|
+
return true;
|
|
768
|
+
}
|
|
769
|
+
if (!current) setGoal(state, key, t(L, 'goal.default'));
|
|
770
|
+
const n = addTask(state, key, body);
|
|
771
|
+
if (!n) { say(t(L, 'task.usage')); return true; }
|
|
772
|
+
ctx.saveState();
|
|
773
|
+
say(`${t(L, 'task.added', { n })}\n${goalView(goalOf(state, key), L)}`);
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
|
|
692
777
|
case '/new': {
|
|
693
778
|
delete state.sessions[key];
|
|
694
779
|
ctx.saveState();
|
package/goal.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Agent-facing helper for the topic goal list.
|
|
3
|
+
//
|
|
4
|
+
// The bridge owns state.json; this script only writes a job file that the
|
|
5
|
+
// bridge drains on its next tick. That keeps one writer and no lock dance.
|
|
6
|
+
//
|
|
7
|
+
// node goal.mjs --key <chat:thread> --status
|
|
8
|
+
// node goal.mjs --key <chat:thread> --add "write tests"
|
|
9
|
+
// node goal.mjs --key <chat:thread> --done 2
|
|
10
|
+
// node goal.mjs --key <chat:thread> --remove 2
|
|
11
|
+
// node goal.mjs --key <chat:thread> --set "new goal"
|
|
12
|
+
// node goal.mjs --key <chat:thread> --finish
|
|
13
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
// State lives where the bridge keeps it, even when this script is executed
|
|
18
|
+
// from the profile's node_modules (plugin install).
|
|
19
|
+
const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
20
|
+
const DIR = process.env.DSH_TG_HOME || join(DSH_HOME, 'tg-bridge');
|
|
21
|
+
const JOBS = join(DIR, 'goal-jobs');
|
|
22
|
+
|
|
23
|
+
function arg(name) {
|
|
24
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
25
|
+
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const key = arg('key');
|
|
29
|
+
if (!key) {
|
|
30
|
+
console.error('usage: node goal.mjs --key <chat:thread> [--status|--add TEXT|--done N|--remove N|--set TEXT|--finish]');
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readState() {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(readFileSync(join(DIR, 'state.json'), 'utf8'));
|
|
37
|
+
} catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function show() {
|
|
43
|
+
const goal = readState().goals?.[key];
|
|
44
|
+
if (!goal) {
|
|
45
|
+
console.log('no goal for this topic');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
console.log(goal.done ? `[closed] ${goal.text}` : goal.text);
|
|
49
|
+
goal.tasks.forEach((t, i) => console.log(`${i + 1}. [${t.done ? 'x' : ' '}] ${t.text}`));
|
|
50
|
+
const done = goal.tasks.filter((t) => t.done).length;
|
|
51
|
+
console.log(`progress: ${done}/${goal.tasks.length}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (process.argv.includes('--status')) {
|
|
55
|
+
show();
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let op;
|
|
60
|
+
if (process.argv.includes('--finish')) op = 'finish';
|
|
61
|
+
else if (process.argv.includes('--clear')) op = 'clear';
|
|
62
|
+
else if (process.argv.includes('--set')) op = 'set';
|
|
63
|
+
else if (process.argv.includes('--add')) op = 'add';
|
|
64
|
+
else if (process.argv.includes('--done')) op = 'done';
|
|
65
|
+
else if (process.argv.includes('--remove')) op = 'remove';
|
|
66
|
+
|
|
67
|
+
if (!op) {
|
|
68
|
+
console.error('nothing to do: pass --status, --set, --add, --done, --remove, --finish or --clear');
|
|
69
|
+
process.exit(2);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const job = { key, op, at: Date.now() };
|
|
73
|
+
if (op === 'set') job.text = arg('set');
|
|
74
|
+
if (op === 'add') job.text = arg('add');
|
|
75
|
+
if (op === 'done') job.index = Number(arg('done'));
|
|
76
|
+
if (op === 'remove') job.index = Number(arg('remove'));
|
|
77
|
+
|
|
78
|
+
mkdirSync(JOBS, { recursive: true });
|
|
79
|
+
const name = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 7)}.json`;
|
|
80
|
+
const tmp = join(JOBS, `${name}.tmp`);
|
|
81
|
+
writeFileSync(tmp, JSON.stringify(job), 'utf8');
|
|
82
|
+
renameSync(tmp, join(JOBS, name));
|
|
83
|
+
console.log(`${op} queued`);
|
package/goals.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Goal + task list for one Telegram topic.
|
|
2
|
+
//
|
|
3
|
+
// State lives in the bridge's state.json (keyed by `chat:thread`), so it
|
|
4
|
+
// survives restarts and is visible to every turn. This module is pure: it
|
|
5
|
+
// mutates a plain object and returns data, never sends messages. The bridge
|
|
6
|
+
// injects the rendered block into the prompt; the agent updates it through
|
|
7
|
+
// `goal.mjs`, which drops a job file the bridge applies.
|
|
8
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const MAX_TASKS = 30;
|
|
12
|
+
export const MAX_GOAL_CHARS = 500;
|
|
13
|
+
|
|
14
|
+
export function goalsOf(state) {
|
|
15
|
+
if (!state.goals || typeof state.goals !== 'object') state.goals = {};
|
|
16
|
+
return state.goals;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function goalOf(state, key) {
|
|
20
|
+
return goalsOf(state)[key] || null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Set or replace the goal text; keeps the existing task list. */
|
|
24
|
+
export function setGoal(state, key, text) {
|
|
25
|
+
const clean = String(text || '').trim().slice(0, MAX_GOAL_CHARS);
|
|
26
|
+
if (!clean) return null;
|
|
27
|
+
const goals = goalsOf(state);
|
|
28
|
+
const now = new Date().toISOString();
|
|
29
|
+
goals[key] = {
|
|
30
|
+
text: clean,
|
|
31
|
+
tasks: goals[key]?.tasks || [],
|
|
32
|
+
done: false,
|
|
33
|
+
createdAt: goals[key]?.createdAt || now,
|
|
34
|
+
updatedAt: now,
|
|
35
|
+
};
|
|
36
|
+
return goals[key];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function clearGoal(state, key) {
|
|
40
|
+
const goals = goalsOf(state);
|
|
41
|
+
if (!goals[key]) return false;
|
|
42
|
+
delete goals[key];
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function finishGoal(state, key) {
|
|
47
|
+
const goal = goalOf(state, key);
|
|
48
|
+
if (!goal) return null;
|
|
49
|
+
goal.done = true;
|
|
50
|
+
goal.updatedAt = new Date().toISOString();
|
|
51
|
+
goal.tasks = goal.tasks.map((t) => ({ ...t, done: true }));
|
|
52
|
+
return goal;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function addTask(state, key, text) {
|
|
56
|
+
const clean = String(text || '').trim().slice(0, MAX_GOAL_CHARS);
|
|
57
|
+
if (!clean) return null;
|
|
58
|
+
const goal = goalOf(state, key);
|
|
59
|
+
if (!goal) return null;
|
|
60
|
+
if (goal.tasks.length >= MAX_TASKS) return null;
|
|
61
|
+
goal.tasks.push({ text: clean, done: false });
|
|
62
|
+
goal.updatedAt = new Date().toISOString();
|
|
63
|
+
return goal.tasks.length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function doneTask(state, key, index) {
|
|
67
|
+
const goal = goalOf(state, key);
|
|
68
|
+
const i = Number(index) - 1;
|
|
69
|
+
if (!goal || !Number.isInteger(i) || i < 0 || i >= goal.tasks.length) return false;
|
|
70
|
+
goal.tasks[i].done = true;
|
|
71
|
+
goal.updatedAt = new Date().toISOString();
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function removeTask(state, key, index) {
|
|
76
|
+
const goal = goalOf(state, key);
|
|
77
|
+
const i = Number(index) - 1;
|
|
78
|
+
if (!goal || !Number.isInteger(i) || i < 0 || i >= goal.tasks.length) return false;
|
|
79
|
+
goal.tasks.splice(i, 1);
|
|
80
|
+
goal.updatedAt = new Date().toISOString();
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function progressOf(goal) {
|
|
85
|
+
const total = goal?.tasks.length || 0;
|
|
86
|
+
const done = goal?.tasks.filter((t) => t.done).length || 0;
|
|
87
|
+
return { total, done };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Compact block prepended to the turn prompt. Empty string when no goal. */
|
|
91
|
+
export function renderPromptBlock(goal, key, goalCmd) {
|
|
92
|
+
if (!goal) return '';
|
|
93
|
+
const lines = ['[Topic goal — set by the person in Telegram]', goal.done ? `${goal.text} (closed)` : goal.text];
|
|
94
|
+
if (goal.tasks.length > 0) {
|
|
95
|
+
lines.push('[Tasks]');
|
|
96
|
+
goal.tasks.forEach((task, i) => lines.push(`${i + 1}. [${task.done ? 'x' : ' '}] ${task.text}`));
|
|
97
|
+
}
|
|
98
|
+
lines.push(
|
|
99
|
+
'[How to update]',
|
|
100
|
+
`When a task is finished, run: node "${goalCmd}" --key "${key}" done <number>`,
|
|
101
|
+
'Do not repeat this block in your answer; report progress in one or two lines.',
|
|
102
|
+
);
|
|
103
|
+
return lines.join('\n');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Apply one job written by goal.mjs. Returns a short status string. */
|
|
107
|
+
export function applyGoalJob(state, job) {
|
|
108
|
+
if (!job || typeof job.key !== 'string' || !job.key) return 'bad job: no key';
|
|
109
|
+
switch (job.op) {
|
|
110
|
+
case 'set':
|
|
111
|
+
return setGoal(state, job.key, job.text) ? 'goal set' : 'empty goal';
|
|
112
|
+
case 'clear':
|
|
113
|
+
return clearGoal(state, job.key) ? 'goal cleared' : 'no goal';
|
|
114
|
+
case 'finish': {
|
|
115
|
+
const goal = finishGoal(state, job.key);
|
|
116
|
+
return goal ? 'goal closed' : 'no goal';
|
|
117
|
+
}
|
|
118
|
+
case 'add':
|
|
119
|
+
if (!goalOf(state, job.key)) setGoal(state, job.key, 'Tasks');
|
|
120
|
+
return addTask(state, job.key, job.text) ? 'task added' : 'task rejected';
|
|
121
|
+
case 'done':
|
|
122
|
+
return doneTask(state, job.key, job.index) ? 'task done' : 'bad task number';
|
|
123
|
+
case 'remove':
|
|
124
|
+
return removeTask(state, job.key, job.index) ? 'task removed' : 'bad task number';
|
|
125
|
+
default:
|
|
126
|
+
return 'unknown op';
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Drain `dir`: apply every *.json job, move it to done/ or failed/.
|
|
132
|
+
* Returns the number of applied jobs.
|
|
133
|
+
*/
|
|
134
|
+
export function drainGoalJobs(state, dir, doneDir) {
|
|
135
|
+
let files;
|
|
136
|
+
try {
|
|
137
|
+
files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
138
|
+
} catch {
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
if (files.length === 0) return 0;
|
|
142
|
+
mkdirSync(doneDir, { recursive: true });
|
|
143
|
+
let applied = 0;
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
const full = join(dir, file);
|
|
146
|
+
let job;
|
|
147
|
+
try {
|
|
148
|
+
job = JSON.parse(readFileSync(full, 'utf8'));
|
|
149
|
+
} catch {
|
|
150
|
+
// Unparseable: move it out of the inbox instead of retrying forever.
|
|
151
|
+
try {
|
|
152
|
+
renameSync(full, join(doneDir, `failed-${file}`));
|
|
153
|
+
} catch {}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const status = applyGoalJob(state, job);
|
|
157
|
+
const ok = !status.startsWith('bad') && status !== 'no goal' && status !== 'empty goal' && status !== 'task rejected';
|
|
158
|
+
if (ok) applied += 1;
|
|
159
|
+
try {
|
|
160
|
+
renameSync(full, join(doneDir, `${ok ? '' : 'failed-'}${file}`));
|
|
161
|
+
} catch {
|
|
162
|
+
try {
|
|
163
|
+
unlinkSync(full);
|
|
164
|
+
} catch {}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return applied;
|
|
168
|
+
}
|
package/i18n.mjs
CHANGED
|
@@ -19,7 +19,7 @@ const DICT = {
|
|
|
19
19
|
'status.failed': 'Turn failed',
|
|
20
20
|
'status.queued': 'queued',
|
|
21
21
|
'status.stop': 'Stop',
|
|
22
|
-
'help.body': "Write the task as plain text. Each forum topic is its own session:\n\nWork: /cd \u003cproject\u003e · /new · /stop · /send \u003cfile\u003e · /seed \u003ctext\u003e\nSessions: /status · /sessions [n] · /resume \u003cid\u003e\nModels: /models · /model \u003cid\u003e · /prices\nExtensions: /skills · /plugins · /plugin add|remove · /soul\nOther: /lang · /doctor · /logs [errors|N] · /app · /restart · /help",
|
|
22
|
+
'help.body': "Write the task as plain text. Each forum topic is its own session:\n\nWork: /cd \u003cproject\u003e · /new · /stop · /send \u003cfile\u003e · /seed \u003ctext\u003e\nGoals: /goal · /task\nSessions: /status · /sessions [n] · /resume \u003cid\u003e\nModels: /models · /model \u003cid\u003e · /prices\nExtensions: /skills · /plugins · /plugin add|remove · /soul\nOther: /lang · /doctor · /logs [errors|N] · /app · /restart · /help",
|
|
23
23
|
'btn.stop': 'Stop',
|
|
24
24
|
'btn.logs': 'Logs',
|
|
25
25
|
'btn.retry': 'Retry',
|
|
@@ -48,6 +48,21 @@ const DICT = {
|
|
|
48
48
|
'cmd.cd.reset': 'Topic directory reset to the shared one.',
|
|
49
49
|
'cmd.cd.none': 'No such directory: {path}',
|
|
50
50
|
'cmd.new.done': 'New session in this topic. Context cleared.',
|
|
51
|
+
'goal.none': 'No goal in this topic. Set one: /goal <text>',
|
|
52
|
+
'goal.set': 'Goal: {text}',
|
|
53
|
+
'goal.default': 'Tasks',
|
|
54
|
+
'goal.closed': 'Goal closed.',
|
|
55
|
+
'goal.cleared': 'Goal and tasks cleared.',
|
|
56
|
+
'goal.progress': '{done}/{total} tasks done',
|
|
57
|
+
'goal.usage': 'Usage: /goal <text> | /goal done | /goal clear',
|
|
58
|
+
'task.none': 'No goal yet — set one with /goal <text>.',
|
|
59
|
+
'task.added': 'Task {n} added.',
|
|
60
|
+
'task.done': 'Task {n} done.',
|
|
61
|
+
'task.removed': 'Task {n} removed.',
|
|
62
|
+
'task.bad': 'No task {n}.',
|
|
63
|
+
'task.empty': 'No tasks yet — /task <text>.',
|
|
64
|
+
'task.usage': 'Usage: /task <text> | /task done <n> | /task rm <n>',
|
|
65
|
+
'status.goal': 'Goal: {done}/{total} · {text}',
|
|
51
66
|
'cmd.stop.killed': 'Turn stopped.',
|
|
52
67
|
'cmd.stop.nothing': 'Nothing to stop.',
|
|
53
68
|
'menu.status': 'Sessions, model, active turns',
|
|
@@ -76,7 +91,7 @@ const DICT = {
|
|
|
76
91
|
'status.failed': 'Ход не удался',
|
|
77
92
|
'status.queued': 'в очереди',
|
|
78
93
|
'status.stop': 'Стоп',
|
|
79
|
-
'help.body': "Задачу можно писать обычным текстом. Каждая ветка форума — отдельная сессия:\n\nРабота: /cd \u003cпроект\u003e · /new · /stop · /send \u003cфайл\u003e · /seed \u003cтекст\u003e\nСессии: /status · /sessions [n] · /resume \u003cid\u003e\nМодели: /models · /model \u003cid\u003e · /prices\nРасширения: /skills · /plugins · /plugin add|remove · /soul\nПрочее: /lang · /doctor · /logs [errors|N] · /app · /restart · /help",
|
|
94
|
+
'help.body': "Задачу можно писать обычным текстом. Каждая ветка форума — отдельная сессия:\n\nРабота: /cd \u003cпроект\u003e · /new · /stop · /send \u003cфайл\u003e · /seed \u003cтекст\u003e\nЦели: /goal · /task\nСессии: /status · /sessions [n] · /resume \u003cid\u003e\nМодели: /models · /model \u003cid\u003e · /prices\nРасширения: /skills · /plugins · /plugin add|remove · /soul\nПрочее: /lang · /doctor · /logs [errors|N] · /app · /restart · /help",
|
|
80
95
|
'btn.stop': 'Стоп',
|
|
81
96
|
'btn.logs': 'Логи',
|
|
82
97
|
'btn.retry': 'Повторить',
|
|
@@ -105,6 +120,21 @@ const DICT = {
|
|
|
105
120
|
'cmd.cd.reset': 'Каталог темы сброшен на общий.',
|
|
106
121
|
'cmd.cd.none': 'Нет такого каталога: {path}',
|
|
107
122
|
'cmd.new.done': 'Новая сессия в этой ветке. Контекст очищен.',
|
|
123
|
+
'goal.none': 'В этой теме нет цели. Задать: /goal <текст>',
|
|
124
|
+
'goal.set': 'Цель: {text}',
|
|
125
|
+
'goal.default': 'Задачи',
|
|
126
|
+
'goal.closed': 'Цель закрыта.',
|
|
127
|
+
'goal.cleared': 'Цель и задачи удалены.',
|
|
128
|
+
'goal.progress': 'Выполнено {done}/{total} задач',
|
|
129
|
+
'goal.usage': 'Использование: /goal <текст> | /goal done | /goal clear',
|
|
130
|
+
'task.none': 'Цели ещё нет — сначала /goal <текст>.',
|
|
131
|
+
'task.added': 'Задача {n} добавлена.',
|
|
132
|
+
'task.done': 'Задача {n} выполнена.',
|
|
133
|
+
'task.removed': 'Задача {n} удалена.',
|
|
134
|
+
'task.bad': 'Нет задачи {n}.',
|
|
135
|
+
'task.empty': 'Задач ещё нет — /task <текст>.',
|
|
136
|
+
'task.usage': 'Использование: /task <текст> | /task done <n> | /task rm <n>',
|
|
137
|
+
'status.goal': 'Цель: {done}/{total} · {text}',
|
|
108
138
|
'cmd.stop.killed': 'Ход остановлен.',
|
|
109
139
|
'cmd.stop.nothing': 'Нечего останавливать.',
|
|
110
140
|
'menu.status': 'Сессии, модель, активные ходы',
|
|
@@ -133,7 +163,7 @@ const DICT = {
|
|
|
133
163
|
'status.failed': '运行失败',
|
|
134
164
|
'status.queued': '排队中',
|
|
135
165
|
'status.stop': '停止',
|
|
136
|
-
'help.body': "任务直接以文本发送。每个论坛话题是一个独立会话:\n\n工作: /cd \u003c项目\u003e · /new · /stop · /send \u003c文件\u003e · /seed \u003c文本\u003e\n会话: /status · /sessions [n] · /resume \u003cid\u003e\n模型: /models · /model \u003cid\u003e · /prices\n扩展: /skills · /plugins · /plugin add|remove · /soul\n其他: /lang · /doctor · /logs [errors|N] · /app · /restart · /help",
|
|
166
|
+
'help.body': "任务直接以文本发送。每个论坛话题是一个独立会话:\n\n工作: /cd \u003c项目\u003e · /new · /stop · /send \u003c文件\u003e · /seed \u003c文本\u003e\n目标: /goal · /task\n会话: /status · /sessions [n] · /resume \u003cid\u003e\n模型: /models · /model \u003cid\u003e · /prices\n扩展: /skills · /plugins · /plugin add|remove · /soul\n其他: /lang · /doctor · /logs [errors|N] · /app · /restart · /help",
|
|
137
167
|
'btn.stop': '停止',
|
|
138
168
|
'btn.logs': '日志',
|
|
139
169
|
'btn.retry': '重试',
|
|
@@ -162,6 +192,21 @@ const DICT = {
|
|
|
162
192
|
'cmd.cd.reset': '话题目录已恢复为默认。',
|
|
163
193
|
'cmd.cd.none': '目录不存在:{path}',
|
|
164
194
|
'cmd.new.done': '已在本话题开始新会话,上下文已清空。',
|
|
195
|
+
'goal.none': '本话题还没有目标。设置:/goal <文本>',
|
|
196
|
+
'goal.set': '目标:{text}',
|
|
197
|
+
'goal.default': '任务',
|
|
198
|
+
'goal.closed': '目标已关闭。',
|
|
199
|
+
'goal.cleared': '目标和任务已清除。',
|
|
200
|
+
'goal.progress': '已完成 {done}/{total} 项任务',
|
|
201
|
+
'goal.usage': '用法:/goal <文本> | /goal done | /goal clear',
|
|
202
|
+
'task.none': '还没有目标——先 /goal <文本>。',
|
|
203
|
+
'task.added': '已添加任务 {n}。',
|
|
204
|
+
'task.done': '任务 {n} 已完成。',
|
|
205
|
+
'task.removed': '已删除任务 {n}。',
|
|
206
|
+
'task.bad': '没有任务 {n}。',
|
|
207
|
+
'task.empty': '还没有任务——/task <文本>。',
|
|
208
|
+
'task.usage': '用法:/task <文本> | /task done <n> | /task rm <n>',
|
|
209
|
+
'status.goal': '目标:{done}/{total} · {text}',
|
|
165
210
|
'cmd.stop.killed': '已停止当前任务。',
|
|
166
211
|
'cmd.stop.nothing': '没有可停止的任务。',
|
|
167
212
|
'menu.status': '会话、模型、进行中的任务',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tg-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Telegram bridge for DeepSeek Harness: text, voice, photos and project-per-topic sessions without opening the desktop app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "plugin-bridge.mjs",
|
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
"plugin-bridge.mjs",
|
|
45
45
|
"cordis.patch.yml",
|
|
46
46
|
"control.mjs",
|
|
47
|
+
"goals.mjs",
|
|
48
|
+
"goal.mjs",
|
|
47
49
|
"i18n.mjs",
|
|
48
50
|
"prices.mjs",
|
|
49
51
|
"setup.mjs",
|