codeep 3.0.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.
- package/dist/config/index.d.ts +6 -0
- package/dist/config/index.js +1 -0
- package/dist/config/providers.js +4 -2
- package/dist/renderer/App.d.ts +5 -1
- package/dist/renderer/App.js +12 -4
- package/dist/renderer/agentExecution.js +62 -5
- package/dist/renderer/components/Settings.js +11 -0
- package/dist/renderer/index.d.ts +12 -6
- package/dist/renderer/layout.d.ts +7 -0
- package/dist/renderer/layout.js +1 -1
- package/dist/renderer/main.js +46 -2
- package/dist/utils/telegramApproval.d.ts +5 -20
- package/dist/utils/telegramApproval.js +16 -55
- package/dist/utils/telegramCredentials.d.ts +8 -0
- package/dist/utils/telegramCredentials.js +15 -0
- package/dist/utils/telegramInbox.d.ts +93 -0
- package/dist/utils/telegramInbox.js +188 -0
- package/dist/utils/telegramNotify.d.ts +102 -0
- package/dist/utils/telegramNotify.js +201 -0
- package/dist/utils/telegramUpdates.d.ts +71 -0
- package/dist/utils/telegramUpdates.js +175 -0
- package/dist/utils/tokenTracker.js +51 -8
- package/dist/utils/tools.js +6 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starting work from the phone.
|
|
3
|
+
*
|
|
4
|
+
* Approval lets the phone answer a question the agent asked. This lets the
|
|
5
|
+
* phone ask one — which is a different thing, and a larger one. A chat that can
|
|
6
|
+
* only say Run, Skip or Cancel is bounded by what the agent already decided to
|
|
7
|
+
* do; a chat that can send a prompt is a keyboard attached to this machine.
|
|
8
|
+
* That is why it is off by default and has its own switch rather than riding on
|
|
9
|
+
* the approval one.
|
|
10
|
+
*/
|
|
11
|
+
import { type TelegramCredentials } from './telegramUpdates';
|
|
12
|
+
/** Longest prompt accepted. Past this it is a paste, not an instruction. */
|
|
13
|
+
export declare const MAX_PROMPT_LENGTH = 2000;
|
|
14
|
+
/**
|
|
15
|
+
* How old a message may be and still run.
|
|
16
|
+
*
|
|
17
|
+
* Telegram keeps undelivered updates for 24 hours. Without this, a message
|
|
18
|
+
* typed at midnight to a machine that was switched off would be handed to the
|
|
19
|
+
* agent the moment the CLI next started — hours later, in a repository that has
|
|
20
|
+
* moved on, with nobody watching. A prompt is an instruction for now.
|
|
21
|
+
*/
|
|
22
|
+
export declare const MAX_MESSAGE_AGE_MS: number;
|
|
23
|
+
export type PromptRejection = 'not-from-owner' | 'not-text' | 'empty' | 'too-long' | 'stale' | 'command';
|
|
24
|
+
export type PromptResult = {
|
|
25
|
+
ok: true;
|
|
26
|
+
text: string;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
reason: PromptRejection;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Whether a message update came from the one chat allowed to drive this agent.
|
|
33
|
+
*
|
|
34
|
+
* A bot's username is discoverable, so without this anyone who found it could
|
|
35
|
+
* type into somebody else's terminal. Mirrors the approval check deliberately:
|
|
36
|
+
* both gate the same machine, and they should not be able to disagree.
|
|
37
|
+
*/
|
|
38
|
+
export declare function messageFromOwner(message: unknown, chatID: string): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Turn a Telegram message into a prompt, or say why it is not one.
|
|
41
|
+
*
|
|
42
|
+
* Returns a reason rather than null throughout: every rejection here has a
|
|
43
|
+
* different thing to tell the sender, and a bot that goes quiet is
|
|
44
|
+
* indistinguishable from one that is switched off.
|
|
45
|
+
*/
|
|
46
|
+
export declare function extractPrompt(message: unknown, chatID: string, now?: number): PromptResult;
|
|
47
|
+
/** What to say back, so silence never has to be interpreted. */
|
|
48
|
+
export declare function describeRejection(reason: PromptRejection): string | null;
|
|
49
|
+
/**
|
|
50
|
+
* The one instruction waiting for the agent to be free.
|
|
51
|
+
*
|
|
52
|
+
* One slot, and a newer message replaces an older one. A queue that grows is a
|
|
53
|
+
* queue that runs things you have forgotten asking for: send two corrections
|
|
54
|
+
* while a run is busy and you meant the second, not both in order. Replacing
|
|
55
|
+
* says so out loud rather than quietly dropping either.
|
|
56
|
+
*/
|
|
57
|
+
export declare class InboxQueue {
|
|
58
|
+
private pending;
|
|
59
|
+
/** @returns whether this displaced an instruction that had not run yet. */
|
|
60
|
+
offer(text: string): {
|
|
61
|
+
replaced: boolean;
|
|
62
|
+
};
|
|
63
|
+
/** Hand over what is waiting, and empty the slot. */
|
|
64
|
+
take(): string | null;
|
|
65
|
+
get waiting(): boolean;
|
|
66
|
+
clear(): void;
|
|
67
|
+
}
|
|
68
|
+
/** Confirmation for a prompt that will run later, so the sender can stop wondering. */
|
|
69
|
+
export declare function describeQueued(replaced: boolean): string;
|
|
70
|
+
/** Confirmation for a prompt that starts immediately. */
|
|
71
|
+
export declare function describeStarted(text: string): string;
|
|
72
|
+
export declare function markRunFromPhone(): void;
|
|
73
|
+
/** True once per run that came from the phone, then false again. */
|
|
74
|
+
export declare function takeRunFromPhone(): boolean;
|
|
75
|
+
export interface InboxHost {
|
|
76
|
+
/** True while a run owns the agent, so a prompt has to wait its turn. */
|
|
77
|
+
isBusy: () => boolean;
|
|
78
|
+
/** Run a prompt as though it had been typed into the input. */
|
|
79
|
+
submit: (text: string) => void;
|
|
80
|
+
/** Say something back on the phone. Failures here are not worth surfacing. */
|
|
81
|
+
reply: (text: string) => void;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Listen for instructions from the phone until the returned function is called.
|
|
85
|
+
*
|
|
86
|
+
* The queue is drained by `drain`, which the host calls when a run ends —
|
|
87
|
+
* rather than polled from here — so that "the agent is free" is decided in one
|
|
88
|
+
* place instead of two that can disagree.
|
|
89
|
+
*/
|
|
90
|
+
export declare function attachTelegramInbox(credentials: TelegramCredentials, host: InboxHost): {
|
|
91
|
+
stop: () => void;
|
|
92
|
+
drain: () => void;
|
|
93
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starting work from the phone.
|
|
3
|
+
*
|
|
4
|
+
* Approval lets the phone answer a question the agent asked. This lets the
|
|
5
|
+
* phone ask one — which is a different thing, and a larger one. A chat that can
|
|
6
|
+
* only say Run, Skip or Cancel is bounded by what the agent already decided to
|
|
7
|
+
* do; a chat that can send a prompt is a keyboard attached to this machine.
|
|
8
|
+
* That is why it is off by default and has its own switch rather than riding on
|
|
9
|
+
* the approval one.
|
|
10
|
+
*/
|
|
11
|
+
import { sharedUpdates } from './telegramUpdates.js';
|
|
12
|
+
/** Longest prompt accepted. Past this it is a paste, not an instruction. */
|
|
13
|
+
export const MAX_PROMPT_LENGTH = 2000;
|
|
14
|
+
/**
|
|
15
|
+
* How old a message may be and still run.
|
|
16
|
+
*
|
|
17
|
+
* Telegram keeps undelivered updates for 24 hours. Without this, a message
|
|
18
|
+
* typed at midnight to a machine that was switched off would be handed to the
|
|
19
|
+
* agent the moment the CLI next started — hours later, in a repository that has
|
|
20
|
+
* moved on, with nobody watching. A prompt is an instruction for now.
|
|
21
|
+
*/
|
|
22
|
+
export const MAX_MESSAGE_AGE_MS = 5 * 60_000;
|
|
23
|
+
/**
|
|
24
|
+
* Whether a message update came from the one chat allowed to drive this agent.
|
|
25
|
+
*
|
|
26
|
+
* A bot's username is discoverable, so without this anyone who found it could
|
|
27
|
+
* type into somebody else's terminal. Mirrors the approval check deliberately:
|
|
28
|
+
* both gate the same machine, and they should not be able to disagree.
|
|
29
|
+
*/
|
|
30
|
+
export function messageFromOwner(message, chatID) {
|
|
31
|
+
if (!chatID)
|
|
32
|
+
return false;
|
|
33
|
+
const chat = message?.chat;
|
|
34
|
+
const id = chat?.id;
|
|
35
|
+
if (typeof id === 'number')
|
|
36
|
+
return String(id) === chatID;
|
|
37
|
+
if (typeof id === 'string')
|
|
38
|
+
return id === chatID;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Turn a Telegram message into a prompt, or say why it is not one.
|
|
43
|
+
*
|
|
44
|
+
* Returns a reason rather than null throughout: every rejection here has a
|
|
45
|
+
* different thing to tell the sender, and a bot that goes quiet is
|
|
46
|
+
* indistinguishable from one that is switched off.
|
|
47
|
+
*/
|
|
48
|
+
export function extractPrompt(message, chatID, now = Date.now()) {
|
|
49
|
+
if (!messageFromOwner(message, chatID))
|
|
50
|
+
return { ok: false, reason: 'not-from-owner' };
|
|
51
|
+
const text = message?.text;
|
|
52
|
+
// A photo, sticker or voice note has no `text` at all.
|
|
53
|
+
if (typeof text !== 'string')
|
|
54
|
+
return { ok: false, reason: 'not-text' };
|
|
55
|
+
const trimmed = text.trim();
|
|
56
|
+
if (trimmed.length === 0)
|
|
57
|
+
return { ok: false, reason: 'empty' };
|
|
58
|
+
if (trimmed.length > MAX_PROMPT_LENGTH)
|
|
59
|
+
return { ok: false, reason: 'too-long' };
|
|
60
|
+
// `/start` and friends are addressed to the bot, not to the agent.
|
|
61
|
+
if (trimmed.startsWith('/'))
|
|
62
|
+
return { ok: false, reason: 'command' };
|
|
63
|
+
const date = message?.date;
|
|
64
|
+
if (typeof date === 'number' && now - date * 1000 > MAX_MESSAGE_AGE_MS) {
|
|
65
|
+
return { ok: false, reason: 'stale' };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, text: trimmed };
|
|
68
|
+
}
|
|
69
|
+
/** What to say back, so silence never has to be interpreted. */
|
|
70
|
+
export function describeRejection(reason) {
|
|
71
|
+
switch (reason) {
|
|
72
|
+
// Answering a stranger confirms the bot is live and attached to something
|
|
73
|
+
// worth probing. They get nothing.
|
|
74
|
+
case 'not-from-owner': return null;
|
|
75
|
+
case 'not-text': return 'I can only take text — send the instruction as a message.';
|
|
76
|
+
case 'empty': return null;
|
|
77
|
+
case 'too-long': return `That is longer than ${MAX_PROMPT_LENGTH} characters. Send a shorter instruction, or paste the text into the terminal.`;
|
|
78
|
+
case 'stale': return 'That message is older than five minutes, so I have not run it. Send it again if you still want it.';
|
|
79
|
+
case 'command': return 'Send an instruction in plain words — I do not take slash commands here.';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The one instruction waiting for the agent to be free.
|
|
84
|
+
*
|
|
85
|
+
* One slot, and a newer message replaces an older one. A queue that grows is a
|
|
86
|
+
* queue that runs things you have forgotten asking for: send two corrections
|
|
87
|
+
* while a run is busy and you meant the second, not both in order. Replacing
|
|
88
|
+
* says so out loud rather than quietly dropping either.
|
|
89
|
+
*/
|
|
90
|
+
export class InboxQueue {
|
|
91
|
+
pending = null;
|
|
92
|
+
/** @returns whether this displaced an instruction that had not run yet. */
|
|
93
|
+
offer(text) {
|
|
94
|
+
const replaced = this.pending !== null;
|
|
95
|
+
this.pending = text;
|
|
96
|
+
return { replaced };
|
|
97
|
+
}
|
|
98
|
+
/** Hand over what is waiting, and empty the slot. */
|
|
99
|
+
take() {
|
|
100
|
+
const out = this.pending;
|
|
101
|
+
this.pending = null;
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
get waiting() {
|
|
105
|
+
return this.pending !== null;
|
|
106
|
+
}
|
|
107
|
+
clear() {
|
|
108
|
+
this.pending = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Confirmation for a prompt that will run later, so the sender can stop wondering. */
|
|
112
|
+
export function describeQueued(replaced) {
|
|
113
|
+
return replaced
|
|
114
|
+
? 'Queued — replaces the one you sent before it. It runs when the current task finishes.'
|
|
115
|
+
: 'Queued — it runs when the current task finishes.';
|
|
116
|
+
}
|
|
117
|
+
/** Confirmation for a prompt that starts immediately. */
|
|
118
|
+
export function describeStarted(text) {
|
|
119
|
+
const short = text.length > 60 ? `${text.slice(0, 57)}…` : text;
|
|
120
|
+
return `Started — ${short}`;
|
|
121
|
+
}
|
|
122
|
+
// ─── Whose run is it ──────────────────────────────────────────────────────────
|
|
123
|
+
/**
|
|
124
|
+
* Whether the run now finishing was started from the phone.
|
|
125
|
+
*
|
|
126
|
+
* It decides whether the agent's answer travels. Start a run at the terminal
|
|
127
|
+
* and the answer is already on the screen you are sitting at; sending it to
|
|
128
|
+
* Telegram would put a file's contents, a command line, or a secret inside an
|
|
129
|
+
* error message into a chat that syncs to Telegram's servers, for nothing. Ask
|
|
130
|
+
* from the phone and the answer is the entire point — you are not at the desk,
|
|
131
|
+
* and "finished, 33K tokens" answers nothing you asked.
|
|
132
|
+
*
|
|
133
|
+
* Module-level because the inbox and the notice sit either side of the run and
|
|
134
|
+
* share no object: the inbox is wired up once in main.ts, the notice fires deep
|
|
135
|
+
* inside executeAgentTask.
|
|
136
|
+
*/
|
|
137
|
+
let startedFromPhone = false;
|
|
138
|
+
export function markRunFromPhone() {
|
|
139
|
+
startedFromPhone = true;
|
|
140
|
+
}
|
|
141
|
+
/** True once per run that came from the phone, then false again. */
|
|
142
|
+
export function takeRunFromPhone() {
|
|
143
|
+
const was = startedFromPhone;
|
|
144
|
+
startedFromPhone = false;
|
|
145
|
+
return was;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Listen for instructions from the phone until the returned function is called.
|
|
149
|
+
*
|
|
150
|
+
* The queue is drained by `drain`, which the host calls when a run ends —
|
|
151
|
+
* rather than polled from here — so that "the agent is free" is decided in one
|
|
152
|
+
* place instead of two that can disagree.
|
|
153
|
+
*/
|
|
154
|
+
export function attachTelegramInbox(credentials, host) {
|
|
155
|
+
const queue = new InboxQueue();
|
|
156
|
+
const unsubscribe = sharedUpdates(credentials.botToken).subscribe('message', message => {
|
|
157
|
+
const result = extractPrompt(message, credentials.chatID);
|
|
158
|
+
if (!result.ok) {
|
|
159
|
+
const explanation = describeRejection(result.reason);
|
|
160
|
+
if (explanation)
|
|
161
|
+
host.reply(explanation);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (host.isBusy()) {
|
|
165
|
+
host.reply(describeQueued(queue.offer(result.text).replaced));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
host.reply(describeStarted(result.text));
|
|
169
|
+
markRunFromPhone();
|
|
170
|
+
host.submit(result.text);
|
|
171
|
+
});
|
|
172
|
+
return {
|
|
173
|
+
stop: () => { unsubscribe(); queue.clear(); },
|
|
174
|
+
drain: () => {
|
|
175
|
+
// Re-check busy: a run can start from the terminal between the last one
|
|
176
|
+
// ending and this firing, and two agents on one workspace is not a thing
|
|
177
|
+
// this queue gets to cause.
|
|
178
|
+
if (host.isBusy())
|
|
179
|
+
return;
|
|
180
|
+
const next = queue.take();
|
|
181
|
+
if (next === null)
|
|
182
|
+
return;
|
|
183
|
+
host.reply(describeStarted(next));
|
|
184
|
+
markRunFromPhone();
|
|
185
|
+
host.submit(next);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { TelegramCredentials } from './telegramApproval';
|
|
2
|
+
/**
|
|
3
|
+
* Runs shorter than this are not worth a notification.
|
|
4
|
+
*
|
|
5
|
+
* The problem this solves is walking away — starting something substantial,
|
|
6
|
+
* making coffee, and not knowing it finished. A `git status` that returned in
|
|
7
|
+
* two seconds was never going to outlast your attention, and a phone that
|
|
8
|
+
* buzzes for every one of those gets muted within a day, taking the
|
|
9
|
+
* notifications that mattered with it.
|
|
10
|
+
*/
|
|
11
|
+
export declare const NOTIFY_AFTER_MS = 60000;
|
|
12
|
+
export declare function shouldNotify(elapsedMs: number, enabled: boolean): boolean;
|
|
13
|
+
/** `92s` / `4m 12s` / `1h 03m` — short enough to read on a lock screen. */
|
|
14
|
+
export declare function formatDuration(ms: number): string;
|
|
15
|
+
/**
|
|
16
|
+
* Telegram refuses a message over 4096 characters outright.
|
|
17
|
+
*
|
|
18
|
+
* Kept well under, because the summary head shares the first message.
|
|
19
|
+
*/
|
|
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[];
|
|
43
|
+
/**
|
|
44
|
+
* Markdown out, plain words in.
|
|
45
|
+
*
|
|
46
|
+
* The agent writes for a terminal that renders markdown, so its answer arrives
|
|
47
|
+
* on a phone as `**140 datoteka**` — asterisks and backticks read as noise
|
|
48
|
+
* exactly where the answer should be easiest to read.
|
|
49
|
+
*
|
|
50
|
+
* Stripped rather than handed to Telegram as `parse_mode`, which would be the
|
|
51
|
+
* obvious fix and the wrong one: Telegram rejects a whole message whose markup
|
|
52
|
+
* is unbalanced, and an agent's answer is arbitrary text. One stray asterisk
|
|
53
|
+
* and the notice does not arrive at all. Ugly beats missing, and this is
|
|
54
|
+
* neither.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately conservative. Only paired markers are touched, and single
|
|
57
|
+
* underscores are left alone entirely — `execute_command` and `snake_case`
|
|
58
|
+
* appear in these answers constantly, and mangling an identifier to italicise
|
|
59
|
+
* nothing is worse than leaving a marker visible.
|
|
60
|
+
*/
|
|
61
|
+
export declare function stripMarkdown(text: string): string;
|
|
62
|
+
export interface RunSummary {
|
|
63
|
+
/** What the run was called — the session display name, not the full prompt. */
|
|
64
|
+
task: string;
|
|
65
|
+
elapsedMs: number;
|
|
66
|
+
/** Set when the run ended on an error, so the message can say so. */
|
|
67
|
+
failure?: string;
|
|
68
|
+
tokens?: number;
|
|
69
|
+
/** Pay-per-use dollars. Omitted on a flat-fee plan, where any figure is invented. */
|
|
70
|
+
costUsd?: number;
|
|
71
|
+
/**
|
|
72
|
+
* The agent's own reply, for a run that was started from the phone.
|
|
73
|
+
*
|
|
74
|
+
* Omitted for a run started at the terminal, where the answer is already on
|
|
75
|
+
* the screen the person is sitting at and sending it would put file contents
|
|
76
|
+
* or a command line into a chat for nothing.
|
|
77
|
+
*/
|
|
78
|
+
answer?: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
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.
|
|
87
|
+
*
|
|
88
|
+
* Carries the agent's answer only when the run was started from the phone. A
|
|
89
|
+
* finished run can end with anything in it — a file it read, a command it ran,
|
|
90
|
+
* a secret inside an error — and this goes to a chat that syncs to Telegram's
|
|
91
|
+
* servers, so it travels only where it was actually asked for. Start a run at
|
|
92
|
+
* the terminal and this says there is a result to come back to, and no more.
|
|
93
|
+
*/
|
|
94
|
+
export declare function composeRunMessages(summary: RunSummary): string[];
|
|
95
|
+
/**
|
|
96
|
+
* Send it, and say nothing if it fails.
|
|
97
|
+
*
|
|
98
|
+
* A notification that could not be delivered is not worth interrupting the
|
|
99
|
+
* terminal for — the run already finished and its result is on screen. Returns
|
|
100
|
+
* whether it went, so a caller that does care can look.
|
|
101
|
+
*/
|
|
102
|
+
export declare function sendTelegramNotice(credentials: TelegramCredentials, text: string): Promise<boolean>;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telling you the run is done, on the phone that already answers its questions.
|
|
3
|
+
*
|
|
4
|
+
* Separate from TelegramApproval on purpose: that class exists to ask a
|
|
5
|
+
* question and wait for one of three answers, and none of its machinery — the
|
|
6
|
+
* keyboard, the token, the outstanding-message bookkeeping — means anything
|
|
7
|
+
* for a message nobody replies to.
|
|
8
|
+
*/
|
|
9
|
+
const API = 'https://api.telegram.org/bot';
|
|
10
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
11
|
+
/**
|
|
12
|
+
* Runs shorter than this are not worth a notification.
|
|
13
|
+
*
|
|
14
|
+
* The problem this solves is walking away — starting something substantial,
|
|
15
|
+
* making coffee, and not knowing it finished. A `git status` that returned in
|
|
16
|
+
* two seconds was never going to outlast your attention, and a phone that
|
|
17
|
+
* buzzes for every one of those gets muted within a day, taking the
|
|
18
|
+
* notifications that mattered with it.
|
|
19
|
+
*/
|
|
20
|
+
export const NOTIFY_AFTER_MS = 60_000;
|
|
21
|
+
export function shouldNotify(elapsedMs, enabled) {
|
|
22
|
+
return enabled && elapsedMs >= NOTIFY_AFTER_MS;
|
|
23
|
+
}
|
|
24
|
+
/** `92s` / `4m 12s` / `1h 03m` — short enough to read on a lock screen. */
|
|
25
|
+
export function formatDuration(ms) {
|
|
26
|
+
const seconds = Math.round(ms / 1000);
|
|
27
|
+
if (seconds < 90)
|
|
28
|
+
return `${seconds}s`;
|
|
29
|
+
const minutes = Math.floor(seconds / 60);
|
|
30
|
+
if (minutes < 60)
|
|
31
|
+
return `${minutes}m ${String(seconds % 60).padStart(2, '0')}s`;
|
|
32
|
+
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Telegram refuses a message over 4096 characters outright.
|
|
36
|
+
*
|
|
37
|
+
* Kept well under, because the summary head shares the first message.
|
|
38
|
+
*/
|
|
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
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Markdown out, plain words in.
|
|
107
|
+
*
|
|
108
|
+
* The agent writes for a terminal that renders markdown, so its answer arrives
|
|
109
|
+
* on a phone as `**140 datoteka**` — asterisks and backticks read as noise
|
|
110
|
+
* exactly where the answer should be easiest to read.
|
|
111
|
+
*
|
|
112
|
+
* Stripped rather than handed to Telegram as `parse_mode`, which would be the
|
|
113
|
+
* obvious fix and the wrong one: Telegram rejects a whole message whose markup
|
|
114
|
+
* is unbalanced, and an agent's answer is arbitrary text. One stray asterisk
|
|
115
|
+
* and the notice does not arrive at all. Ugly beats missing, and this is
|
|
116
|
+
* neither.
|
|
117
|
+
*
|
|
118
|
+
* Deliberately conservative. Only paired markers are touched, and single
|
|
119
|
+
* underscores are left alone entirely — `execute_command` and `snake_case`
|
|
120
|
+
* appear in these answers constantly, and mangling an identifier to italicise
|
|
121
|
+
* nothing is worse than leaving a marker visible.
|
|
122
|
+
*/
|
|
123
|
+
export function stripMarkdown(text) {
|
|
124
|
+
return text
|
|
125
|
+
// Fenced blocks: keep the code, drop the fence and any language tag.
|
|
126
|
+
.replace(/```[a-zA-Z0-9-]*\n?([\s\S]*?)```/g, '$1')
|
|
127
|
+
// Headings, which a phone shows as literal hashes.
|
|
128
|
+
.replace(/^#{1,6}[ \t]+/gm, '')
|
|
129
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
130
|
+
.replace(/__([^_]+)__/g, '$1')
|
|
131
|
+
// Single backticks only — a lone one is left alone rather than eating the
|
|
132
|
+
// rest of the answer looking for a partner.
|
|
133
|
+
.replace(/`([^`\n]+)`/g, '$1')
|
|
134
|
+
.trim();
|
|
135
|
+
}
|
|
136
|
+
/** The head: what ran, how long it took, what it cost. Never the answer. */
|
|
137
|
+
function composeHead(summary) {
|
|
138
|
+
const head = summary.failure ? '⚠️ Codeep stopped' : '✅ Codeep finished';
|
|
139
|
+
const lines = [`${head} — ${summary.task}`, `took ${formatDuration(summary.elapsedMs)}`];
|
|
140
|
+
if (summary.failure)
|
|
141
|
+
lines.push(summary.failure);
|
|
142
|
+
const cost = [];
|
|
143
|
+
if (typeof summary.tokens === 'number' && summary.tokens > 0) {
|
|
144
|
+
cost.push(`${Math.round(summary.tokens / 1000)}K tokens`);
|
|
145
|
+
}
|
|
146
|
+
if (typeof summary.costUsd === 'number' && summary.costUsd > 0) {
|
|
147
|
+
cost.push(`$${summary.costUsd.toFixed(summary.costUsd < 0.01 ? 4 : 2)}`);
|
|
148
|
+
}
|
|
149
|
+
if (cost.length > 0)
|
|
150
|
+
lines.push(cost.join(' · '));
|
|
151
|
+
return lines.join('\n');
|
|
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
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Send it, and say nothing if it fails.
|
|
176
|
+
*
|
|
177
|
+
* A notification that could not be delivered is not worth interrupting the
|
|
178
|
+
* terminal for — the run already finished and its result is on screen. Returns
|
|
179
|
+
* whether it went, so a caller that does care can look.
|
|
180
|
+
*/
|
|
181
|
+
export async function sendTelegramNotice(credentials, text) {
|
|
182
|
+
try {
|
|
183
|
+
const response = await fetch(`${API}${credentials.botToken}/sendMessage`, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'Content-Type': 'application/json' },
|
|
186
|
+
body: JSON.stringify({
|
|
187
|
+
chat_id: credentials.chatID,
|
|
188
|
+
text,
|
|
189
|
+
// No parse_mode: a task name is arbitrary user text and Telegram
|
|
190
|
+
// rejects the whole message on unbalanced Markdown, which would turn a
|
|
191
|
+
// stray underscore in a branch name into a silently dropped notice.
|
|
192
|
+
disable_notification: false,
|
|
193
|
+
}),
|
|
194
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
195
|
+
});
|
|
196
|
+
return response.ok;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One long poll, one offset, both kinds of update.
|
|
3
|
+
*
|
|
4
|
+
* Telegram's getUpdates has a single cursor per bot. `offset` confirms every
|
|
5
|
+
* update older than itself **regardless of `allowed_updates`** — that parameter
|
|
6
|
+
* only filters what comes back in the response, not what the call acknowledges.
|
|
7
|
+
* So two pollers on one bot do not coexist: each advances the cursor past
|
|
8
|
+
* updates the other never saw, and both start losing traffic silently. An
|
|
9
|
+
* approval tapped on the phone would simply not register, with nothing anywhere
|
|
10
|
+
* to say why.
|
|
11
|
+
*
|
|
12
|
+
* Hence this. Everything that wants updates subscribes here, the loop asks for
|
|
13
|
+
* every type any subscriber could want, and dispatch happens locally where
|
|
14
|
+
* losing one is impossible.
|
|
15
|
+
*/
|
|
16
|
+
export interface TelegramCredentials {
|
|
17
|
+
/** From @BotFather. A credential — belongs in the keychain, never in config. */
|
|
18
|
+
botToken: string;
|
|
19
|
+
/** The single chat allowed to answer. Anything else is ignored. */
|
|
20
|
+
chatID: string;
|
|
21
|
+
}
|
|
22
|
+
export type UpdateKind = 'callback_query' | 'message';
|
|
23
|
+
export type UpdateHandler = (payload: unknown) => void | Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Told when a poll fails, and when one succeeds after failing.
|
|
26
|
+
*
|
|
27
|
+
* Without this the loop was completely silent: a webhook left configured on the
|
|
28
|
+
* bot answers 409 to every getUpdates, a revoked token answers 401, and both
|
|
29
|
+
* looked exactly like a phone nobody had messaged. Diagnosing it meant reading
|
|
30
|
+
* the source.
|
|
31
|
+
*/
|
|
32
|
+
export type PollObserver = (event: {
|
|
33
|
+
ok: boolean;
|
|
34
|
+
detail: string;
|
|
35
|
+
}) => void;
|
|
36
|
+
/**
|
|
37
|
+
* Where the cursor goes after a batch.
|
|
38
|
+
*
|
|
39
|
+
* Never backwards: a retry that returns an older batch, or a response with ids
|
|
40
|
+
* this build does not understand, must not re-deliver what was already handled.
|
|
41
|
+
*/
|
|
42
|
+
export declare function nextOffset(current: number, updates: {
|
|
43
|
+
update_id?: unknown;
|
|
44
|
+
}[]): number;
|
|
45
|
+
/** Every kind this loop asks for, so one cursor can serve every subscriber. */
|
|
46
|
+
export declare const POLLED_KINDS: readonly UpdateKind[];
|
|
47
|
+
export declare class TelegramUpdates {
|
|
48
|
+
private readonly botToken;
|
|
49
|
+
private readonly handlers;
|
|
50
|
+
private offset;
|
|
51
|
+
private running;
|
|
52
|
+
private readonly idlePauseMs;
|
|
53
|
+
private observer;
|
|
54
|
+
/** Only the first failure of a streak is reported, then the recovery. */
|
|
55
|
+
private failing;
|
|
56
|
+
constructor(botToken: string, idlePauseMs?: number);
|
|
57
|
+
/** Watch the health of the poll itself, separately from its payload. */
|
|
58
|
+
observe(observer: PollObserver | null): void;
|
|
59
|
+
private report;
|
|
60
|
+
/**
|
|
61
|
+
* Listen for one kind of update. Returns the function that stops listening.
|
|
62
|
+
*
|
|
63
|
+
* The loop runs while anyone is listening and stops when the last subscriber
|
|
64
|
+
* leaves, so a CLI with the inbox switched off never opens a connection.
|
|
65
|
+
*/
|
|
66
|
+
subscribe(kind: UpdateKind, handler: UpdateHandler): () => void;
|
|
67
|
+
private subscriberCount;
|
|
68
|
+
private loop;
|
|
69
|
+
private getUpdates;
|
|
70
|
+
}
|
|
71
|
+
export declare function sharedUpdates(botToken: string): TelegramUpdates;
|