@iletai/nzb 1.3.4 → 1.3.6

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.
@@ -395,26 +395,28 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
395
395
  processQueue();
396
396
  });
397
397
  // Deliver response to user FIRST, then log best-effort
398
- callback(finalContent, true);
399
398
  try {
400
399
  logMessage("out", sourceLabel, finalContent);
401
400
  }
402
401
  catch {
403
402
  /* best-effort */
404
403
  }
405
- // Log both sides of the conversation after delivery
404
+ // Log both sides of the conversation before delivery so we have the row ID
405
+ let assistantLogId;
406
406
  try {
407
- logConversation(logRole, prompt, sourceLabel);
407
+ const telegramMsgId = source.type === "telegram" ? source.messageId : undefined;
408
+ logConversation(logRole, prompt, sourceLabel, telegramMsgId);
408
409
  }
409
410
  catch {
410
411
  /* best-effort */
411
412
  }
412
413
  try {
413
- logConversation("assistant", finalContent, sourceLabel);
414
+ assistantLogId = logConversation("assistant", finalContent, sourceLabel);
414
415
  }
415
416
  catch {
416
417
  /* best-effort */
417
418
  }
419
+ callback(finalContent, true, { assistantLogId });
418
420
  // Auto-continue: if the response was cut short by timeout, automatically
419
421
  // send a follow-up "Continue" message so the user doesn't have to
420
422
  if (finalContent.includes("⏱ Response was cut short (timeout)")) {
package/dist/store/db.js CHANGED
@@ -33,9 +33,19 @@ export function getDb() {
33
33
  role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
34
34
  content TEXT NOT NULL,
35
35
  source TEXT NOT NULL DEFAULT 'unknown',
36
+ telegram_msg_id INTEGER,
36
37
  ts DATETIME DEFAULT CURRENT_TIMESTAMP
37
38
  )
38
39
  `);
40
+ // Migrate: add telegram_msg_id column if missing
41
+ try {
42
+ db.prepare(`SELECT telegram_msg_id FROM conversation_log LIMIT 1`).get();
43
+ }
44
+ catch {
45
+ db.exec(`ALTER TABLE conversation_log ADD COLUMN telegram_msg_id INTEGER`);
46
+ }
47
+ // Index for fast reply-to lookups
48
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_conv_telegram_msg ON conversation_log (telegram_msg_id)`);
39
49
  db.exec(`
40
50
  CREATE TABLE IF NOT EXISTS memories (
41
51
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -60,6 +70,7 @@ export function getDb() {
60
70
  role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
61
71
  content TEXT NOT NULL,
62
72
  source TEXT NOT NULL DEFAULT 'unknown',
73
+ telegram_msg_id INTEGER,
63
74
  ts DATETIME DEFAULT CURRENT_TIMESTAMP
64
75
  )
65
76
  `);
@@ -83,11 +94,12 @@ export function getDb() {
83
94
  getState: db.prepare(`SELECT value FROM nzb_state WHERE key = ?`),
84
95
  setState: db.prepare(`INSERT OR REPLACE INTO nzb_state (key, value) VALUES (?, ?)`),
85
96
  deleteState: db.prepare(`DELETE FROM nzb_state WHERE key = ?`),
86
- logConversation: db.prepare(`INSERT INTO conversation_log (role, content, source) VALUES (?, ?, ?)`),
97
+ logConversation: db.prepare(`INSERT INTO conversation_log (role, content, source, telegram_msg_id) VALUES (?, ?, ?, ?)`),
87
98
  pruneConversation: db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 200)`),
88
99
  addMemory: db.prepare(`INSERT INTO memories (category, content, source) VALUES (?, ?, ?)`),
89
100
  removeMemory: db.prepare(`DELETE FROM memories WHERE id = ?`),
90
101
  memorySummary: db.prepare(`SELECT id, category, content FROM memories ORDER BY category, last_accessed DESC`),
102
+ getConversationByMsgId: db.prepare(`SELECT id FROM conversation_log WHERE telegram_msg_id = ? LIMIT 1`),
91
103
  };
92
104
  }
93
105
  return db;
@@ -106,15 +118,49 @@ export function deleteState(key) {
106
118
  getDb(); // ensure init
107
119
  stmtCache.deleteState.run(key);
108
120
  }
109
- /** Log a conversation turn (user, assistant, or system). */
110
- export function logConversation(role, content, source) {
121
+ /** Log a conversation turn (user, assistant, or system) with optional Telegram message ID. Returns the row ID. */
122
+ export function logConversation(role, content, source, telegramMsgId) {
111
123
  getDb(); // ensure init
112
- stmtCache.logConversation.run(role, content, source);
124
+ const result = stmtCache.logConversation.run(role, content, source, telegramMsgId ?? null);
113
125
  // Keep last 200 entries to support context recovery after session loss
114
126
  logInsertCount++;
115
127
  if (logInsertCount % 50 === 0) {
116
128
  stmtCache.pruneConversation.run();
117
129
  }
130
+ return result.lastInsertRowid;
131
+ }
132
+ /** Get conversation context around a Telegram message ID (±4 rows using proper subquery). */
133
+ export function getConversationContext(telegramMsgId) {
134
+ const db = getDb();
135
+ const row = stmtCache.getConversationByMsgId.get(telegramMsgId);
136
+ if (!row)
137
+ return undefined;
138
+ // Fetch 4 rows before + the target + 4 rows after (handles ID gaps from pruning)
139
+ const rows = db.prepare(`
140
+ SELECT role, content, source, ts FROM (
141
+ SELECT * FROM conversation_log WHERE id < ? ORDER BY id DESC LIMIT 4
142
+ )
143
+ UNION ALL
144
+ SELECT role, content, source, ts FROM conversation_log WHERE id = ?
145
+ UNION ALL
146
+ SELECT role, content, source, ts FROM (
147
+ SELECT * FROM conversation_log WHERE id > ? ORDER BY id ASC LIMIT 4
148
+ )
149
+ `).all(row.id, row.id, row.id);
150
+ if (rows.length === 0)
151
+ return undefined;
152
+ return rows
153
+ .map((r) => {
154
+ const tag = r.role === "user" ? "You" : r.role === "assistant" ? "NZB" : "System";
155
+ const content = r.content.length > 400 ? r.content.slice(0, 400) + "…" : r.content;
156
+ return `${tag}: ${content}`;
157
+ })
158
+ .join("\n");
159
+ }
160
+ /** Set Telegram message ID on a specific conversation_log row (race-free). */
161
+ export function setConversationTelegramMsgId(rowId, telegramMsgId) {
162
+ const db = getDb();
163
+ db.prepare(`UPDATE conversation_log SET telegram_msg_id = ? WHERE id = ?`).run(telegramMsgId, rowId);
118
164
  }
119
165
  /** Get recent conversation history formatted for injection into system message. */
120
166
  export function getRecentConversation(limit = 20) {
@@ -439,17 +439,26 @@ export function createBot() {
439
439
  const onUsage = (usage) => {
440
440
  usageInfo = usage;
441
441
  };
442
- // If user replies to a message, include that message's text as context
442
+ // If user replies to a message, include surrounding conversation context
443
443
  let userPrompt = ctx.message.text;
444
444
  const replyMsg = ctx.message.reply_to_message;
445
445
  if (replyMsg && "text" in replyMsg && replyMsg.text) {
446
- const quoted = replyMsg.text.length > 500 ? replyMsg.text.slice(0, 500) + "…" : replyMsg.text;
447
- userPrompt = `[Replying to: "${quoted}"]\n\n${userPrompt}`;
446
+ // Try to find full conversation context around the replied message
447
+ const { getConversationContext } = await import("../store/db.js");
448
+ const context = getConversationContext(replyMsg.message_id);
449
+ if (context) {
450
+ userPrompt = `[Continuing from earlier conversation:]\n---\n${context}\n---\n\n[Your reply]: ${userPrompt}`;
451
+ }
452
+ else {
453
+ const quoted = replyMsg.text.length > 500 ? replyMsg.text.slice(0, 500) + "…" : replyMsg.text;
454
+ userPrompt = `[Replying to: "${quoted}"]\n\n${userPrompt}`;
455
+ }
448
456
  }
449
- sendToOrchestrator(userPrompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
457
+ sendToOrchestrator(userPrompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done, meta) => {
450
458
  if (done) {
451
459
  finalized = true;
452
460
  stopTyping();
461
+ const assistantLogId = meta?.assistantLogId;
453
462
  const elapsed = ((Date.now() - handlerStartTime) / 1000).toFixed(1);
454
463
  void logInfo(`✅ Response done (${elapsed}s, ${toolHistory.length} tools, ${text.length} chars)`);
455
464
  // Wait for in-flight edits to finish before sending the final response
@@ -505,6 +514,13 @@ export function createBot() {
505
514
  await bot.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
506
515
  }
507
516
  catch { }
517
+ if (assistantLogId) {
518
+ try {
519
+ const { setConversationTelegramMsgId } = await import("../store/db.js");
520
+ setConversationTelegramMsgId(assistantLogId, placeholderMsgId);
521
+ }
522
+ catch { }
523
+ }
508
524
  return;
509
525
  }
510
526
  catch {
@@ -514,6 +530,13 @@ export function createBot() {
514
530
  await bot.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
515
531
  }
516
532
  catch { }
533
+ if (assistantLogId) {
534
+ try {
535
+ const { setConversationTelegramMsgId } = await import("../store/db.js");
536
+ setConversationTelegramMsgId(assistantLogId, placeholderMsgId);
537
+ }
538
+ catch { }
539
+ }
517
540
  return;
518
541
  }
519
542
  catch {
@@ -523,6 +546,7 @@ export function createBot() {
523
546
  }
524
547
  // Multi-chunk or edit fallthrough: send new chunks FIRST, then delete placeholder
525
548
  const totalChunks = chunks.length;
549
+ let firstSentMsgId;
526
550
  const sendChunk = async (chunk, fallback, index) => {
527
551
  const isFirst = index === 0 && !placeholderMsgId;
528
552
  // Pagination header for multi-chunk messages
@@ -530,9 +554,11 @@ export function createBot() {
530
554
  const opts = isFirst
531
555
  ? { parse_mode: "MarkdownV2", reply_parameters: replyParams }
532
556
  : { parse_mode: "MarkdownV2" };
533
- await ctx
557
+ const sent = await ctx
534
558
  .reply(pageTag + chunk, opts)
535
559
  .catch(() => ctx.reply(pageTag + fallback, isFirst ? { reply_parameters: replyParams } : {}));
560
+ if (index === 0 && sent)
561
+ firstSentMsgId = sent.message_id;
536
562
  };
537
563
  let sendSucceeded = false;
538
564
  try {
@@ -549,7 +575,9 @@ export function createBot() {
549
575
  if (i > 0)
550
576
  await new Promise((r) => setTimeout(r, 300));
551
577
  const pageTag = fallbackChunks.length > 1 ? `📄 ${i + 1}/${fallbackChunks.length}\n` : "";
552
- await ctx.reply(pageTag + fallbackChunks[i], i === 0 ? { reply_parameters: replyParams } : {});
578
+ const sent = await ctx.reply(pageTag + fallbackChunks[i], i === 0 ? { reply_parameters: replyParams } : {});
579
+ if (i === 0 && sent)
580
+ firstSentMsgId = sent.message_id;
553
581
  }
554
582
  sendSucceeded = true;
555
583
  }
@@ -566,6 +594,15 @@ export function createBot() {
566
594
  /* ignore — placeholder stays but user has the real message */
567
595
  }
568
596
  }
597
+ // Track bot message ID for reply-to context lookups
598
+ const botMsgId = firstSentMsgId ?? placeholderMsgId;
599
+ if (assistantLogId && botMsgId) {
600
+ try {
601
+ const { setConversationTelegramMsgId } = await import("../store/db.js");
602
+ setConversationTelegramMsgId(assistantLogId, botMsgId);
603
+ }
604
+ catch { }
605
+ }
569
606
  // React ✅ on the user's original message to signal completion
570
607
  try {
571
608
  await bot.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
@@ -751,6 +788,20 @@ export function createBot() {
751
788
  await ctx.reply("❌ Voice too long (max 5 min).", { reply_parameters: { message_id: userMessageId } });
752
789
  return;
753
790
  }
791
+ // If voice is a reply, include context
792
+ let voiceReplyContext = "";
793
+ const voiceReplyMsg = ctx.message.reply_to_message;
794
+ if (voiceReplyMsg && "text" in voiceReplyMsg && voiceReplyMsg.text) {
795
+ const { getConversationContext } = await import("../store/db.js");
796
+ const context = getConversationContext(voiceReplyMsg.message_id);
797
+ if (context) {
798
+ voiceReplyContext = `[Continuing from earlier conversation:]\n---\n${context}\n---\n\n`;
799
+ }
800
+ else {
801
+ const quoted = voiceReplyMsg.text.length > 500 ? voiceReplyMsg.text.slice(0, 500) + "…" : voiceReplyMsg.text;
802
+ voiceReplyContext = `[Replying to: "${quoted}"]\n\n`;
803
+ }
804
+ }
754
805
  try {
755
806
  const file = await ctx.api.getFile(ctx.message.voice.file_id);
756
807
  const filePath = file.file_path;
@@ -801,31 +852,44 @@ export function createBot() {
801
852
  else {
802
853
  prompt = `[User sent a voice message (${duration}s), saved at: ${localPath}. No OPENAI_API_KEY configured for transcription. You can tell the user to set it up in ~/.nzb/.env]`;
803
854
  }
804
- sendToOrchestrator(prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
855
+ sendToOrchestrator(voiceReplyContext + prompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done, meta) => {
805
856
  if (done) {
857
+ const assistantLogId = meta?.assistantLogId;
806
858
  const formatted = toTelegramMarkdown(text);
807
859
  const chunks = chunkMessage(formatted);
808
860
  const fallbackChunks = chunkMessage(text);
809
861
  void (async () => {
862
+ let firstMsgId;
810
863
  for (let i = 0; i < chunks.length; i++) {
811
864
  if (i > 0)
812
865
  await new Promise((r) => setTimeout(r, 300));
813
866
  const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
814
867
  try {
815
- await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
868
+ const sent = await ctx.api.sendMessage(chatId, pageTag + chunks[i], {
816
869
  parse_mode: "MarkdownV2",
817
870
  reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
818
871
  });
872
+ if (i === 0)
873
+ firstMsgId = sent.message_id;
819
874
  }
820
875
  catch {
821
876
  try {
822
- await ctx.api.sendMessage(chatId, pageTag + (fallbackChunks[i] ?? chunks[i]), {
877
+ const sent = await ctx.api.sendMessage(chatId, pageTag + (fallbackChunks[i] ?? chunks[i]), {
823
878
  reply_parameters: i === 0 ? { message_id: userMessageId } : undefined,
824
879
  });
880
+ if (i === 0)
881
+ firstMsgId = sent.message_id;
825
882
  }
826
883
  catch { }
827
884
  }
828
885
  }
886
+ if (assistantLogId && firstMsgId) {
887
+ try {
888
+ const { setConversationTelegramMsgId } = await import("../store/db.js");
889
+ setConversationTelegramMsgId(assistantLogId, firstMsgId);
890
+ }
891
+ catch { }
892
+ }
829
893
  try {
830
894
  await ctx.api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
831
895
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iletai/nzb",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
4
4
  "description": "NZB — a personal AI assistant for developers, built on the GitHub Copilot SDK",
5
5
  "bin": {
6
6
  "nzb": "dist/cli.js"