codeep 3.1.0 → 3.1.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.
@@ -9,7 +9,7 @@ import { chat } from '../api/index.js';
9
9
  import { runAgent } from '../utils/agent.js';
10
10
  import { TelegramApproval, outcomeForAnswer, describePermissionOutcome } from '../utils/telegramApproval.js';
11
11
  import { loadTelegramCredentials } from '../utils/telegramCredentials.js';
12
- import { composeRunSummary, sendTelegramNotice, shouldNotify } from '../utils/telegramNotify.js';
12
+ import { composeRunMessages, sendTelegramNotice, shouldNotify } from '../utils/telegramNotify.js';
13
13
  import { takeRunFromPhone } from '../utils/telegramInbox.js';
14
14
  import { isFlatFeeProvider } from '../config/providers.js';
15
15
  import { raceApproval } from '../utils/approvalRace.js';
@@ -470,13 +470,19 @@ export async function executeAgentTask(task, dryRun, ctx) {
470
470
  // and withholding it leaves "Started —" as the last word.
471
471
  if (fromPhone || shouldNotify(elapsedMs, true)) {
472
472
  const payPerUse = costBreakdown.filter(entry => !isFlatFeeProvider(entry.provider));
473
- await sendTelegramNotice(noticeCredentials, composeRunSummary({
473
+ // Usually one message. An answer past Telegram's limit continues into
474
+ // further ones rather than being cut at the first — awaited in turn so
475
+ // they arrive in the order they were written.
476
+ const messages = composeRunMessages({
474
477
  task: runLabel,
475
478
  elapsedMs,
476
479
  answer: fromPhone ? result.finalResponse : undefined,
477
480
  tokens: costBreakdown.reduce((sum, e) => sum + e.promptTokens + e.completionTokens, 0),
478
481
  costUsd: payPerUse.reduce((sum, e) => sum + e.estimatedCost, 0),
479
- })).catch(() => false);
482
+ });
483
+ for (const message of messages) {
484
+ await sendTelegramNotice(noticeCredentials, message).catch(() => false);
485
+ }
480
486
  }
481
487
  }
482
488
  const sharedFields = {
@@ -15,10 +15,31 @@ export declare function formatDuration(ms: number): string;
15
15
  /**
16
16
  * Telegram refuses a message over 4096 characters outright.
17
17
  *
18
- * Kept well under: the rest of the summary shares the message, and a reply
19
- * that fills a phone screen twice over is not read on a phone anyway.
18
+ * Kept well under, because the summary head shares the first message.
20
19
  */
21
20
  export declare const MAX_ANSWER_LENGTH = 3000;
21
+ /**
22
+ * How many messages one answer may become.
23
+ *
24
+ * A long answer is worth several; an enormous one is not worth a phone buzzing
25
+ * eleven times, and past a point nobody is reading it there anyway. Three is
26
+ * enough for an explanation and short of a flood.
27
+ */
28
+ export declare const MAX_ANSWER_PARTS = 3;
29
+ /**
30
+ * An answer as the messages to send, in order.
31
+ *
32
+ * Cutting at the limit was honest — it said it had cut — but lossy in the place
33
+ * it hurts: an answer that lists files or walks through a change passes 3000
34
+ * characters easily, and the conclusion is at the end. So it is split instead,
35
+ * on a paragraph or line boundary where there is one nearby, and only what
36
+ * exceeds three messages is cut.
37
+ *
38
+ * Mirrors the Mac app's `TelegramAnswerText.partsForPhone` deliberately: two
39
+ * implementations of "what does the phone get" that can disagree is worse than
40
+ * either.
41
+ */
42
+ export declare function splitAnswer(text: string): string[];
22
43
  /**
23
44
  * Markdown out, plain words in.
24
45
  *
@@ -57,7 +78,12 @@ export interface RunSummary {
57
78
  answer?: string;
58
79
  }
59
80
  /**
60
- * The notification text.
81
+ * The notification, as the messages to send in order.
82
+ *
83
+ * One message when the answer fits, which is the common case. A longer answer
84
+ * continues into further messages rather than being cut at the first limit —
85
+ * see `splitAnswer`. The head shares the first message, so the reply is not a
86
+ * bare wall of text with no idea which run it belongs to.
61
87
  *
62
88
  * Carries the agent's answer only when the run was started from the phone. A
63
89
  * finished run can end with anything in it — a file it read, a command it ran,
@@ -65,7 +91,7 @@ export interface RunSummary {
65
91
  * servers, so it travels only where it was actually asked for. Start a run at
66
92
  * the terminal and this says there is a result to come back to, and no more.
67
93
  */
68
- export declare function composeRunSummary(summary: RunSummary): string;
94
+ export declare function composeRunMessages(summary: RunSummary): string[];
69
95
  /**
70
96
  * Send it, and say nothing if it fails.
71
97
  *
@@ -34,10 +34,74 @@ export function formatDuration(ms) {
34
34
  /**
35
35
  * Telegram refuses a message over 4096 characters outright.
36
36
  *
37
- * Kept well under: the rest of the summary shares the message, and a reply
38
- * that fills a phone screen twice over is not read on a phone anyway.
37
+ * Kept well under, because the summary head shares the first message.
39
38
  */
40
39
  export const MAX_ANSWER_LENGTH = 3000;
40
+ /**
41
+ * How many messages one answer may become.
42
+ *
43
+ * A long answer is worth several; an enormous one is not worth a phone buzzing
44
+ * eleven times, and past a point nobody is reading it there anyway. Three is
45
+ * enough for an explanation and short of a flood.
46
+ */
47
+ export const MAX_ANSWER_PARTS = 3;
48
+ /**
49
+ * An answer as the messages to send, in order.
50
+ *
51
+ * Cutting at the limit was honest — it said it had cut — but lossy in the place
52
+ * it hurts: an answer that lists files or walks through a change passes 3000
53
+ * characters easily, and the conclusion is at the end. So it is split instead,
54
+ * on a paragraph or line boundary where there is one nearby, and only what
55
+ * exceeds three messages is cut.
56
+ *
57
+ * Mirrors the Mac app's `TelegramAnswerText.partsForPhone` deliberately: two
58
+ * implementations of "what does the phone get" that can disagree is worse than
59
+ * either.
60
+ */
61
+ export function splitAnswer(text) {
62
+ const plain = text.trim();
63
+ if (!plain)
64
+ return [];
65
+ const parts = [];
66
+ let rest = plain;
67
+ while (rest.length > 0 && parts.length < MAX_ANSWER_PARTS) {
68
+ if (rest.length <= MAX_ANSWER_LENGTH) {
69
+ parts.push(rest);
70
+ rest = '';
71
+ break;
72
+ }
73
+ const cut = breakPoint(rest);
74
+ parts.push(rest.slice(0, cut).trim());
75
+ rest = rest.slice(cut).replace(/^[\n ]+/, '');
76
+ }
77
+ // Say it was cut rather than ending mid-sentence and looking finished.
78
+ if (rest.length > 0 && parts.length > 0) {
79
+ parts[parts.length - 1] += '\n\n[…cut — the full answer is in the terminal]';
80
+ }
81
+ return parts;
82
+ }
83
+ /**
84
+ * Where to end a part: the last paragraph break in the final third of the
85
+ * allowance, else the last line break, else the last space.
86
+ *
87
+ * Splitting mid-word reads as damage; splitting mid-paragraph reads as a
88
+ * continuation. Only the tail is searched so a single long paragraph does not
89
+ * send a 200-character message and push the rest into the next one.
90
+ */
91
+ function breakPoint(text) {
92
+ const earliest = Math.floor((MAX_ANSWER_LENGTH * 2) / 3);
93
+ const window = text.slice(earliest, MAX_ANSWER_LENGTH);
94
+ const paragraph = window.lastIndexOf('\n\n');
95
+ if (paragraph >= 0)
96
+ return earliest + paragraph;
97
+ const line = window.lastIndexOf('\n');
98
+ if (line >= 0)
99
+ return earliest + line;
100
+ const space = window.lastIndexOf(' ');
101
+ if (space >= 0)
102
+ return earliest + space;
103
+ return MAX_ANSWER_LENGTH;
104
+ }
41
105
  /**
42
106
  * Markdown out, plain words in.
43
107
  *
@@ -69,16 +133,8 @@ export function stripMarkdown(text) {
69
133
  .replace(/`([^`\n]+)`/g, '$1')
70
134
  .trim();
71
135
  }
72
- /**
73
- * The notification text.
74
- *
75
- * Carries the agent's answer only when the run was started from the phone. A
76
- * finished run can end with anything in it — a file it read, a command it ran,
77
- * a secret inside an error — and this goes to a chat that syncs to Telegram's
78
- * servers, so it travels only where it was actually asked for. Start a run at
79
- * the terminal and this says there is a result to come back to, and no more.
80
- */
81
- export function composeRunSummary(summary) {
136
+ /** The head: what ran, how long it took, what it cost. Never the answer. */
137
+ function composeHead(summary) {
82
138
  const head = summary.failure ? '⚠️ Codeep stopped' : '✅ Codeep finished';
83
139
  const lines = [`${head} — ${summary.task}`, `took ${formatDuration(summary.elapsedMs)}`];
84
140
  if (summary.failure)
@@ -92,16 +148,29 @@ export function composeRunSummary(summary) {
92
148
  }
93
149
  if (cost.length > 0)
94
150
  lines.push(cost.join(' · '));
95
- const answer = summary.answer ? stripMarkdown(summary.answer) : undefined;
96
- if (answer) {
97
- lines.push('');
98
- lines.push(answer.length > MAX_ANSWER_LENGTH
99
- // Say it was cut rather than ending mid-sentence and looking finished.
100
- ? `${answer.slice(0, MAX_ANSWER_LENGTH)}\n\n[…cut — the full answer is in the terminal]`
101
- : answer);
102
- }
103
151
  return lines.join('\n');
104
152
  }
153
+ /**
154
+ * The notification, as the messages to send in order.
155
+ *
156
+ * One message when the answer fits, which is the common case. A longer answer
157
+ * continues into further messages rather than being cut at the first limit —
158
+ * see `splitAnswer`. The head shares the first message, so the reply is not a
159
+ * bare wall of text with no idea which run it belongs to.
160
+ *
161
+ * Carries the agent's answer only when the run was started from the phone. A
162
+ * finished run can end with anything in it — a file it read, a command it ran,
163
+ * a secret inside an error — and this goes to a chat that syncs to Telegram's
164
+ * servers, so it travels only where it was actually asked for. Start a run at
165
+ * the terminal and this says there is a result to come back to, and no more.
166
+ */
167
+ export function composeRunMessages(summary) {
168
+ const head = composeHead(summary);
169
+ const parts = summary.answer ? splitAnswer(stripMarkdown(summary.answer)) : [];
170
+ if (parts.length === 0)
171
+ return [head];
172
+ return [`${head}\n\n${parts[0]}`, ...parts.slice(1)];
173
+ }
105
174
  /**
106
175
  * Send it, and say nothing if it fails.
107
176
  *
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "3.1.0";
1
+ export declare const VERSION = "3.1.1";
package/dist/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // AUTO-GENERATED by scripts/gen-version.js — do not edit by hand.
2
2
  // Baked from package.json at build time so the bun-compiled binary reports
3
3
  // the right version (it has no package.json on disk to read at runtime).
4
- export const VERSION = '3.1.0';
4
+ export const VERSION = '3.1.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeep",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",