dsh-tg-bridge 1.4.1 → 1.5.1

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 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,41 @@ 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
+ ### Live thoughts
210
+
211
+ The agent can leave a short decision trail inside the progress message instead of sending
212
+ extra chat messages:
213
+
214
+ ```bash
215
+ node ~/.dsh/tg-bridge/say.mjs --thought "checking why the watchdog fired twice"
216
+ ```
217
+
218
+ The note is appended to `~/.dsh/tg-bridge/thoughts/<chat>_<thread>.jsonl`; the progress
219
+ message shows the last two as `💭` lines. Inside a turn the chat and thread come from
220
+ `DSH_TG_CHAT` / `DSH_TG_THREAD`, so the command needs no ids. `--thought` never sends a
221
+ Telegram message on its own.
222
+
223
+ ### Goals and tasks
224
+
225
+ A topic carries its own goal and checklist, so the aim survives `/new`, a
226
+ restart and a different session:
227
+
228
+ ```
229
+ /goal ship the release set or replace the goal
230
+ /goal show the goal and the checklist
231
+ /task write the migration add a task
232
+ /task done 2 tick it off
233
+ /task rm 2 remove it
234
+ /goal done close the goal (all tasks done)
235
+ /goal clear delete the goal and its tasks
236
+ ```
237
+
238
+ The bridge prepends the goal and the list to every turn, with the exact command
239
+ that updates it (`goal.mjs`). The agent ticks tasks as it finishes them, and the
240
+ next turn sees the new state. The bridge is the only writer of `state.json`: the
241
+ agent drops a job file into `goal-jobs/` and the bridge applies it, so there is
242
+ no lock race and no lost update.
243
+
207
244
  ## Security
208
245
 
209
246
  - `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,11 +35,16 @@ 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');
40
45
  const PENDING_DIR = join(DIR, 'pending');
41
46
  const STEER_DIR = join(DIR, 'steer');
47
+ const THOUGHT_DIR = join(DIR, 'thoughts');
42
48
  // The helper the agent calls to confirm an addition in the chat while the turn
43
49
  // is still running. Lives next to the bridge so an installed copy works too.
44
50
  const SAY = join(DIR, 'say.mjs');
@@ -140,6 +146,18 @@ function writeAtomic(path, text) {
140
146
  function saveState() {
141
147
  writeAtomic(STATE, JSON.stringify(state, null, 2));
142
148
  }
149
+
150
+ // The agent updates the topic goal list by dropping a job file (goal.mjs);
151
+ // the bridge is the only writer of state.json, so there is no lock race.
152
+ const GOAL_DIR = join(DIR, 'goal-jobs');
153
+ setInterval(() => {
154
+ try {
155
+ if (drainGoalJobs(state, GOAL_DIR, join(GOAL_DIR, 'done'))) saveState();
156
+ } catch (error) {
157
+ log(`goal job failed: ${error.message}`);
158
+ }
159
+ }, 2000).unref();
160
+
143
161
  function log(line) {
144
162
  const text = `${new Date().toISOString()} ${line}\n`;
145
163
  try { appendFileSync(LOG, text, 'utf8'); } catch {}
@@ -718,6 +736,9 @@ function runTurn(task, sessionId, key = '', onEvent = null, extraArgs = [], cwd
718
736
  cwd: cwd && existsSync(cwd) ? cwd : config.cwd,
719
737
  env: {
720
738
  ...process.env,
739
+ DSH_TG_KEY: key,
740
+ DSH_TG_CHAT: String(key).split(':')[0] || '',
741
+ DSH_TG_THREAD: String(key).split(':')[1] || '0',
721
742
  ELECTRON_RUN_AS_NODE: '1',
722
743
  UNOROUTER_API_KEY: readSecret('UNOROUTER_API_KEY'),
723
744
  // Free fallback providers: their keys live next to the main one and are
@@ -854,6 +875,34 @@ async function startProgress(chatId, threadId, lang = 'ru') {
854
875
  let dirty = false;
855
876
  let timer = null;
856
877
 
878
+ // Short "💭" notes the agent appends with `say.mjs --thought`. The progress
879
+ // message shows the last two, so the person sees the decision trail without
880
+ // a second stream of chat messages.
881
+ const thoughtFile = join(THOUGHT_DIR, `${chatId}_${threadId || 0}.jsonl`);
882
+ let thoughts = [];
883
+ try {
884
+ mkdirSync(THOUGHT_DIR, { recursive: true });
885
+ writeFileSync(thoughtFile, '');
886
+ } catch {}
887
+ const thoughtTimer = setInterval(() => {
888
+ let next = [];
889
+ try {
890
+ next = readFileSync(thoughtFile, 'utf8')
891
+ .split('\n')
892
+ .filter(Boolean)
893
+ .slice(-2)
894
+ .map((line) => {
895
+ try { return String(JSON.parse(line).text || '').slice(0, 140); } catch { return ''; }
896
+ })
897
+ .filter(Boolean);
898
+ } catch {}
899
+ if (next.join('\n') !== thoughts.join('\n')) {
900
+ thoughts = next;
901
+ schedule();
902
+ }
903
+ }, 3000);
904
+ thoughtTimer.unref?.();
905
+
857
906
  const body = (final = false) => {
858
907
  const seconds = Math.round((Date.now() - started) / 1000);
859
908
  const head = final
@@ -863,6 +912,7 @@ async function startProgress(chatId, threadId, lang = 'ru') {
863
912
  // shell commands read as noise on a phone.
864
913
  const tail = steps.slice(-3).map((line) => `• ${line}`).join('\n');
865
914
  const more = steps.length > 3 ? `\n${t(lang, 'status.more', { n: steps.length - 3 })}` : '';
915
+ const mind = thoughts.length ? `\n${thoughts.map((line) => `💭 ${line}`).join('\n')}` : '';
866
916
  // The cache line belongs to the outcome, not to every intermediate edit.
867
917
  const cache = final && usage.cacheReadTokens
868
918
  ? `\n${t(lang, 'status.cacheLine', {
@@ -870,7 +920,7 @@ async function startProgress(chatId, threadId, lang = 'ru') {
870
920
  k: Math.round(usage.cacheReadTokens / 1000),
871
921
  })}`
872
922
  : '';
873
- return (tail ? `${head}\n${tail}${more}${cache}` : `${head}${cache}`);
923
+ return (tail ? `${head}${mind}\n${tail}${more}${cache}` : `${head}${mind}${cache}`);
874
924
  };
875
925
 
876
926
  try {
@@ -952,6 +1002,7 @@ async function startProgress(chatId, threadId, lang = 'ru') {
952
1002
  },
953
1003
  async finish(result) {
954
1004
  clearTimeout(timer);
1005
+ clearInterval(thoughtTimer);
955
1006
  if (result?.error) {
956
1007
  // A failed turn leaves the status message as the error report and swaps
957
1008
  // «Стоп» for «Повторить»: the person's next move is a retry, not a stop.
@@ -1230,6 +1281,11 @@ ${t(lang, 'help.body')}`,
1230
1281
  // Project directory bound to this topic (set with /cd); config.cwd otherwise.
1231
1282
  const cwd = (state.cwd && state.cwd[key]) || config.cwd;
1232
1283
 
1284
+ // Desktop-style goal: the topic's goal and task list travel with every turn,
1285
+ // so a fresh session after /new still knows what the project is aiming at.
1286
+ const topicGoal = goalOf(state, key);
1287
+ if (topicGoal) text = `${renderPromptBlock(topicGoal, key, GOAL_CMD)}\n\n---\n\n${text}`;
1288
+
1233
1289
  // Live progress: one status message per turn, rewritten as the agent works.
1234
1290
  // Without it a long turn and a hung turn looked exactly alike in the chat.
1235
1291
  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.4.1",
3
+ "version": "1.5.1",
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",
package/say.mjs CHANGED
@@ -7,16 +7,21 @@
7
7
  //
8
8
  // node say.mjs --steer <job-id> "text"
9
9
  // node say.mjs --chat <chat-id> [--thread <id>] "text"
10
+ // node say.mjs --thought "short note" (chat/thread come from DSH_TG_CHAT/DSH_TG_THREAD)
11
+ //
12
+ // `--thought` does not send a message: it appends to the turn's thought file,
13
+ // and the live progress message shows the last two notes as 💭 lines.
10
14
  //
11
15
  // With `--steer` the chat/thread come from the job file and an ack marker is
12
16
  // written, which suppresses the bridge's fallback line.
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
18
  import { homedir } from 'node:os';
15
19
  import { join } from 'node:path';
16
20
 
17
21
  const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
18
22
  const STEER = join(DSH_HOME, 'tg-bridge', 'steer');
19
23
  const ACKS = join(STEER, 'acks');
24
+ const THOUGHTS = join(DSH_HOME, 'tg-bridge', 'thoughts');
20
25
 
21
26
  function findToken() {
22
27
  const env = (process.env.DSH_TELEGRAM_BOT_TOKEN || '').trim();
@@ -39,18 +44,19 @@ function jobOf(id) {
39
44
  }
40
45
 
41
46
  function usage() {
42
- console.error('usage: node say.mjs --steer <id> "text" | --chat <id> [--thread <id>] "text"');
47
+ console.error('usage: node say.mjs --steer <id> "text" | --chat <id> [--thread <id>] "text" | --thought "text"');
43
48
  process.exit(2);
44
49
  }
45
50
 
46
51
  function parse(argv) {
47
- const out = { chatId: 0, threadId: 0, steer: '', text: '' };
52
+ const out = { chatId: 0, threadId: 0, steer: '', thought: false, text: '' };
48
53
  const rest = [];
49
54
  for (let i = 0; i < argv.length; i += 1) {
50
55
  const arg = argv[i];
51
56
  if (arg === '--steer') out.steer = argv[++i] || '';
52
57
  else if (arg === '--chat') out.chatId = Number(argv[++i] || 0);
53
58
  else if (arg === '--thread') out.threadId = Number(argv[++i] || 0);
59
+ else if (arg === '--thought') out.thought = true;
54
60
  else rest.push(arg);
55
61
  }
56
62
  out.text = rest.join(' ').trim();
@@ -61,8 +67,8 @@ async function main() {
61
67
  const args = parse(process.argv.slice(2));
62
68
  if (!args.text) usage();
63
69
 
64
- let chatId = args.chatId;
65
- let threadId = args.threadId;
70
+ let chatId = args.chatId || Number(process.env.DSH_TG_CHAT || 0);
71
+ let threadId = args.threadId || Number(process.env.DSH_TG_THREAD || 0);
66
72
  let jobPath = '';
67
73
  if (args.steer) {
68
74
  const found = jobOf(args.steer);
@@ -76,6 +82,19 @@ async function main() {
76
82
  }
77
83
  if (!chatId) usage();
78
84
 
85
+ if (args.thought) {
86
+ try {
87
+ mkdirSync(THOUGHTS, { recursive: true });
88
+ const file = join(THOUGHTS, `${chatId}_${threadId || 0}.jsonl`);
89
+ appendFileSync(file, `${JSON.stringify({ at: Date.now(), text: args.text })}\n`, 'utf8');
90
+ console.log(file);
91
+ } catch (error) {
92
+ console.error(String(error?.message ?? error));
93
+ process.exit(6);
94
+ }
95
+ return;
96
+ }
97
+
79
98
  const token = findToken();
80
99
  if (!token) {
81
100
  console.error('bot token not found (DSH_TELEGRAM_BOT_TOKEN or ~/.dsh/.credentials.yaml)');