@amenophis1er/foreman 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +408 -0
- package/LICENSE +15 -0
- package/README.md +133 -0
- package/bin/foreman.mjs +58 -0
- package/package.json +68 -0
- package/scripts/prepare.mjs +48 -0
- package/skills/director/SKILL.md +65 -0
- package/src/anthropic-models.ts +54 -0
- package/src/ask.test.ts +88 -0
- package/src/ask.ts +95 -0
- package/src/attachments.test.ts +33 -0
- package/src/attachments.ts +60 -0
- package/src/cli.test.ts +27 -0
- package/src/cli.ts +297 -0
- package/src/codex.test.ts +328 -0
- package/src/codex.ts +196 -0
- package/src/cost-basis.test.ts +76 -0
- package/src/deck.test.ts +402 -0
- package/src/deck.ts +892 -0
- package/src/fork.test.ts +31 -0
- package/src/gateway/ledger.cjs +326 -0
- package/src/gateway/ledger.test.ts +255 -0
- package/src/gateway/llm-gateway.cjs +1411 -0
- package/src/gateway/llm-gateway.test.ts +478 -0
- package/src/gateway.test.ts +226 -0
- package/src/gateway.ts +309 -0
- package/src/instance.ts +124 -0
- package/src/models.test.ts +147 -0
- package/src/models.ts +158 -0
- package/src/notify/commands.test.ts +28 -0
- package/src/notify/commands.ts +73 -0
- package/src/notify/telegram.ts +259 -0
- package/src/notify.test.ts +343 -0
- package/src/notify.ts +495 -0
- package/src/ollama.test.ts +49 -0
- package/src/ollama.ts +49 -0
- package/src/openai-prices.test.ts +58 -0
- package/src/openai-prices.ts +106 -0
- package/src/orchestrator.test.ts +1147 -0
- package/src/orchestrator.ts +2325 -0
- package/src/planner.test.ts +60 -0
- package/src/planner.ts +505 -0
- package/src/policy.test.ts +411 -0
- package/src/policy.ts +599 -0
- package/src/preflight.ts +348 -0
- package/src/prices.test.ts +69 -0
- package/src/prices.ts +90 -0
- package/src/provider.test.ts +366 -0
- package/src/provider.ts +502 -0
- package/src/secrets.test.ts +143 -0
- package/src/secrets.ts +66 -0
- package/src/server.ts +1992 -0
- package/src/services.test.ts +53 -0
- package/src/services.ts +102 -0
- package/src/sse-events.test.ts +83 -0
- package/src/store.test.ts +119 -0
- package/src/store.ts +346 -0
- package/src/tailscale.test.ts +32 -0
- package/src/tailscale.ts +79 -0
- package/src/title.ts +138 -0
- package/src/types.ts +442 -0
- package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
- package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
- package/ui/dist/favicon.svg +8 -0
- package/ui/dist/index.html +14 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint model discovery.
|
|
3
|
+
*
|
|
4
|
+
* The behaviour worth pinning down is which probe wins and what "no answer"
|
|
5
|
+
* means: an endpoint that cannot be reached is not the same as one with no
|
|
6
|
+
* models, and conflating them would show an empty picker for a typo'd host.
|
|
7
|
+
*/
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import http from 'node:http';
|
|
11
|
+
import { costRank, describeModel, discoverModels } from './models.js';
|
|
12
|
+
|
|
13
|
+
/** A stub endpoint; `routes` maps a path to [status, body]. */
|
|
14
|
+
function stub(routes: Record<string, [number, unknown]>): Promise<{
|
|
15
|
+
url: string; hits: string[]; auth: (string | undefined)[]; close: () => Promise<void>;
|
|
16
|
+
}> {
|
|
17
|
+
const hits: string[] = [];
|
|
18
|
+
const auth: (string | undefined)[] = [];
|
|
19
|
+
const server = http.createServer((req, res) => {
|
|
20
|
+
hits.push(req.url ?? '');
|
|
21
|
+
auth.push(req.headers.authorization as string | undefined);
|
|
22
|
+
const hit = routes[req.url ?? ''];
|
|
23
|
+
if (!hit) { res.writeHead(404); res.end('{}'); return; }
|
|
24
|
+
res.writeHead(hit[0], { 'content-type': 'application/json' });
|
|
25
|
+
res.end(JSON.stringify(hit[1]));
|
|
26
|
+
});
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
server.listen(0, '127.0.0.1', () => {
|
|
29
|
+
const a = server.address();
|
|
30
|
+
const port = typeof a === 'object' && a ? a.port : 0;
|
|
31
|
+
resolve({
|
|
32
|
+
url: `http://127.0.0.1:${port}`, hits, auth,
|
|
33
|
+
close: () => new Promise((r) => server.close(() => r())),
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const TAGS = {
|
|
40
|
+
models: [
|
|
41
|
+
{ name: 'kimi-k3:cloud', remote_model: 'kimi-k3', remote_host: 'https://ollama.com', details: { parameter_size: '2.81T' } },
|
|
42
|
+
{ name: 'smollm:135m', details: { parameter_size: '134.52M' } },
|
|
43
|
+
{ name: 'ornith:9b', details: { parameter_size: '9.0B' } },
|
|
44
|
+
{ name: '', details: {} },
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
test('Ollama’s richer listing is preferred where it answers', async (t) => {
|
|
49
|
+
const s = await stub({ '/api/tags': [200, TAGS] });
|
|
50
|
+
t.after(() => s.close());
|
|
51
|
+
const models = await discoverModels(s.url, { timeoutMs: 800 });
|
|
52
|
+
assert.ok(models);
|
|
53
|
+
// Alphabetical: a cloud model is not a lesser option to be listed last.
|
|
54
|
+
assert.deepEqual(models.map((m) => m.id), ['kimi-k3:cloud', 'ornith:9b', 'smollm:135m']);
|
|
55
|
+
assert.equal(models[0].remote, true);
|
|
56
|
+
assert.equal(models[0].host, 'https://ollama.com');
|
|
57
|
+
assert.equal(models[2].size, '134.52M');
|
|
58
|
+
assert.deepEqual(s.hits, ['/api/tags'], '/v1/models must not be probed once tags answered');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('anything OpenAI-compatible falls back to /v1/models', async (t) => {
|
|
62
|
+
const s = await stub({ '/v1/models': [200, { data: [{ id: 'gpt-5' }, { id: 'glm-5.3-flash' }] }] });
|
|
63
|
+
t.after(() => s.close());
|
|
64
|
+
const models = await discoverModels(s.url, { timeoutMs: 800 });
|
|
65
|
+
assert.deepEqual(models?.map((m) => m.id), ['glm-5.3-flash', 'gpt-5']);
|
|
66
|
+
assert.equal(models?.[0].remote, false, 'the OpenAI shape cannot express this, so do not claim it');
|
|
67
|
+
assert.deepEqual(s.hits, ['/api/tags', '/v1/models']);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('unreachable is null, and is not the same as empty', async (t) => {
|
|
71
|
+
// Nothing listening: a typo'd host must be distinguishable from a daemon
|
|
72
|
+
// with nothing pulled, or the picker lies about which it is.
|
|
73
|
+
assert.equal(await discoverModels('http://127.0.0.1:1', { timeoutMs: 300 }), null);
|
|
74
|
+
|
|
75
|
+
const s = await stub({ '/api/tags': [200, { models: [] }] });
|
|
76
|
+
t.after(() => s.close());
|
|
77
|
+
assert.deepEqual(await discoverModels(s.url, { timeoutMs: 800 }), []);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a pasted /v1 suffix or trailing slash is tolerated', async (t) => {
|
|
81
|
+
const s = await stub({ '/api/tags': [200, TAGS] });
|
|
82
|
+
t.after(() => s.close());
|
|
83
|
+
for (const suffix of ['', '/', '/v1']) {
|
|
84
|
+
const models = await discoverModels(s.url + suffix, { timeoutMs: 800 });
|
|
85
|
+
assert.equal(models?.length, 3, `failed for ${JSON.stringify(suffix)}`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a key is sent when the endpoint needs one to list', async (t) => {
|
|
90
|
+
const s = await stub({ '/v1/models': [200, { data: [{ id: 'x' }] }] });
|
|
91
|
+
t.after(() => s.close());
|
|
92
|
+
await discoverModels(s.url, { apiKey: 'sk-test', timeoutMs: 800 });
|
|
93
|
+
assert.ok(s.auth.every((a) => a === 'Bearer sk-test'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('the note names the trade rather than ranking the options', () => {
|
|
97
|
+
assert.match(describeModel({ id: 'a', remote: true, host: 'https://ollama.com' }), /ollama\.com.*Fast/s);
|
|
98
|
+
assert.match(describeModel({ id: 'b', remote: false, size: '9.0B' }), /Local · 9\.0B.*Free and private/s);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// What a picker is told about price
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
test('a published rate leads the description, in per-million terms', () => {
|
|
106
|
+
// Per-token is how the source publishes and how prices.ts stores it; per
|
|
107
|
+
// million is how a person reads a pricing page. The conversion happens here
|
|
108
|
+
// and nowhere else.
|
|
109
|
+
assert.equal(
|
|
110
|
+
describeModel({ id: 'x', remote: false, price: { input: 0.000002, output: 0.00001 } }),
|
|
111
|
+
'$2.00 in / $10.00 out per million tokens.',
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a sub-cent rate is not rounded away to nothing', () => {
|
|
116
|
+
// "$0.00 in / $0.00 out" would say free about a model that is not.
|
|
117
|
+
assert.equal(
|
|
118
|
+
describeModel({ id: 'x', remote: false, price: { input: 0.00000005, output: 0.0000004 } }),
|
|
119
|
+
'$0.050 in / $0.400 out per million tokens.',
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('a model published at zero is described as free at that endpoint', () => {
|
|
124
|
+
assert.equal(
|
|
125
|
+
describeModel({ id: 'x', remote: false, price: { input: 0, output: 0 } }),
|
|
126
|
+
'Free at this endpoint.',
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('an unpriced remote model says the rate is not visible, rather than implying none', () => {
|
|
131
|
+
const note = describeModel({ id: 'x', remote: true, host: 'https://ollama.com' });
|
|
132
|
+
assert.match(note, /billed to that account/);
|
|
133
|
+
assert.match(note, /Foreman cannot see/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('cost bars come from the real rate where there is one', () => {
|
|
137
|
+
const rank = (output: number) => costRank({ id: 'x', remote: true, price: { input: 0, output } });
|
|
138
|
+
assert.equal(rank(0), 0);
|
|
139
|
+
assert.equal(rank(0.0000005), 1); // $0.50/Mtok
|
|
140
|
+
assert.equal(rank(0.000002), 2); // $2
|
|
141
|
+
assert.equal(rank(0.00001), 3); // $10
|
|
142
|
+
assert.equal(rank(0.00006), 4); // $60
|
|
143
|
+
// With no rate published, the old constant stands: local is free, remote is
|
|
144
|
+
// a guess, and the note beside it says the rate is unknown.
|
|
145
|
+
assert.equal(costRank({ id: 'x', remote: false }), 0);
|
|
146
|
+
assert.equal(costRank({ id: 'x', remote: true }), 2);
|
|
147
|
+
});
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What models does an endpoint actually have?
|
|
3
|
+
*
|
|
4
|
+
* Every `openai-compatible` provider can answer this, and the answer belongs
|
|
5
|
+
* to the *endpoint*, not to the server: a project pointed at an Ollama on
|
|
6
|
+
* another machine must be offered that machine's models, not this one's.
|
|
7
|
+
*
|
|
8
|
+
* Two probes, richest first:
|
|
9
|
+
*
|
|
10
|
+
* - `GET /api/tags` — Ollama's own. Worth preferring where it answers,
|
|
11
|
+
* because it distinguishes a local model from a `:cloud` one and reports
|
|
12
|
+
* parameter counts, and those are the facts a person actually chooses on.
|
|
13
|
+
* - `GET /v1/models` — the OpenAI-compatible standard. Works for ollama.com
|
|
14
|
+
* direct, OpenRouter, vLLM, LM Studio, and anything else in that family,
|
|
15
|
+
* but returns bare ids.
|
|
16
|
+
*
|
|
17
|
+
* Neither throws. A picker that cannot reach its endpoint shows an empty list
|
|
18
|
+
* and says so; it does not fail a page.
|
|
19
|
+
*/
|
|
20
|
+
import { parsePricing, type ModelPrice } from './prices.js';
|
|
21
|
+
|
|
22
|
+
export interface EndpointModel {
|
|
23
|
+
/** Exactly what the API expects as a model id. */
|
|
24
|
+
id: string;
|
|
25
|
+
/** Parameter count where the endpoint reports one. */
|
|
26
|
+
size?: string;
|
|
27
|
+
/** Runs somewhere other than the endpoint's own machine (Ollama `:cloud`). */
|
|
28
|
+
remote: boolean;
|
|
29
|
+
/** Where a remote model actually runs, e.g. `https://ollama.com`. */
|
|
30
|
+
host?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Per-token rates, where the endpoint publishes them (OpenRouter does).
|
|
33
|
+
*
|
|
34
|
+
* This is the only price Foreman will ever put a dollar sign on for a
|
|
35
|
+
* gateway run: it comes from the party that sends the bill. Absent means
|
|
36
|
+
* unpriced, and unpriced is left saying so — see prices.ts.
|
|
37
|
+
*/
|
|
38
|
+
price?: ModelPrice;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DiscoverOptions {
|
|
42
|
+
/** Bearer credential, for endpoints that need one to list models. */
|
|
43
|
+
apiKey?: string;
|
|
44
|
+
/** Discovery runs on page loads and preflight; it must not hang either. */
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function getJson(url: string, apiKey?: string, timeoutMs = 1500): Promise<unknown | null> {
|
|
49
|
+
const res = await fetch(url, {
|
|
50
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
51
|
+
headers: apiKey ? { authorization: `Bearer ${apiKey}` } : undefined,
|
|
52
|
+
}).catch(() => null);
|
|
53
|
+
if (!res?.ok) return null;
|
|
54
|
+
return res.json().catch(() => null);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Ollama's `/api/tags`, which knows more than the OpenAI shape can express. */
|
|
58
|
+
function fromOllamaTags(body: unknown): EndpointModel[] | null {
|
|
59
|
+
const models = (body as { models?: unknown })?.models;
|
|
60
|
+
if (!Array.isArray(models)) return null;
|
|
61
|
+
const out: EndpointModel[] = [];
|
|
62
|
+
for (const m of models as Array<Record<string, any>>) {
|
|
63
|
+
if (typeof m?.name !== 'string' || !m.name) continue;
|
|
64
|
+
out.push({
|
|
65
|
+
id: m.name,
|
|
66
|
+
size: m.details?.parameter_size || undefined,
|
|
67
|
+
remote: Boolean(m.remote_model),
|
|
68
|
+
host: m.remote_host || undefined,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The OpenAI-compatible `/v1/models` listing: ids, and rates where the
|
|
76
|
+
* endpoint volunteers them. OpenAI's own listing carries none; OpenRouter's
|
|
77
|
+
* carries a full `pricing` object per model.
|
|
78
|
+
*/
|
|
79
|
+
function fromOpenAiList(body: unknown): EndpointModel[] | null {
|
|
80
|
+
const data = (body as { data?: unknown })?.data;
|
|
81
|
+
if (!Array.isArray(data)) return null;
|
|
82
|
+
const out: EndpointModel[] = [];
|
|
83
|
+
for (const m of data as Array<Record<string, any>>) {
|
|
84
|
+
if (typeof m?.id !== 'string' || !m.id) continue;
|
|
85
|
+
const price = parsePricing(m.pricing);
|
|
86
|
+
out.push({ id: m.id, remote: false, ...(price ? { price } : {}) });
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Models an OpenAI-compatible endpoint offers, or null if it cannot be
|
|
93
|
+
* reached. `baseUrl` is the host root — the same value a provider stores.
|
|
94
|
+
*/
|
|
95
|
+
export async function discoverModels(
|
|
96
|
+
baseUrl: string,
|
|
97
|
+
{ apiKey, timeoutMs = 1500 }: DiscoverOptions = {},
|
|
98
|
+
): Promise<EndpointModel[] | null> {
|
|
99
|
+
const root = baseUrl.replace(/\/+$/, '').replace(/\/v1$/, '');
|
|
100
|
+
|
|
101
|
+
const tags = fromOllamaTags(await getJson(`${root}/api/tags`, apiKey, timeoutMs));
|
|
102
|
+
// An Ollama with nothing pulled answers with an empty list, which is a real
|
|
103
|
+
// answer — fall through to /v1/models only when the probe did not apply.
|
|
104
|
+
const models = tags ?? fromOpenAiList(await getJson(`${root}/v1/models`, apiKey, timeoutMs));
|
|
105
|
+
if (!models) return null;
|
|
106
|
+
|
|
107
|
+
// Plain alphabetical. A cloud model is not a lesser option to be listed after
|
|
108
|
+
// the real ones — it is the one fast enough to direct a mission with.
|
|
109
|
+
return models.sort((a, b) => a.id.localeCompare(b.id));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Per-million-token rates, which is how a pricing page reads.
|
|
114
|
+
*
|
|
115
|
+
* The conversion lives here, in the one place that renders a rate for a
|
|
116
|
+
* person — prices.ts deliberately keeps the source's own per-token unit so
|
|
117
|
+
* there is exactly one factor of a million to get wrong.
|
|
118
|
+
*/
|
|
119
|
+
function rateLine(price: ModelPrice): string {
|
|
120
|
+
const perM = (n: number) => {
|
|
121
|
+
const v = n * 1_000_000;
|
|
122
|
+
// Sub-cent rates are real and common; rounding them to "$0.00" would say
|
|
123
|
+
// free about something that is not.
|
|
124
|
+
return v >= 1 ? `$${v.toFixed(2)}` : `$${v.toFixed(3)}`;
|
|
125
|
+
};
|
|
126
|
+
if (price.input === 0 && price.output === 0) return 'Free at this endpoint.';
|
|
127
|
+
return `${perM(price.input)} in / ${perM(price.output)} out per million tokens.`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The picker's 0–4 cost bars, from a real rate where there is one.
|
|
132
|
+
*
|
|
133
|
+
* A rank, not a figure — the line under it carries the actual number. Banded
|
|
134
|
+
* on the output rate because output dominates what an agent run spends, and a
|
|
135
|
+
* constant guess (every remote model was "2") ranked a cheap flash model
|
|
136
|
+
* level with a flagship.
|
|
137
|
+
*/
|
|
138
|
+
export function costRank(m: EndpointModel): 0 | 1 | 2 | 3 | 4 {
|
|
139
|
+
if (!m.price) return m.remote ? 2 : 0;
|
|
140
|
+
const perM = m.price.output * 1_000_000;
|
|
141
|
+
if (perM === 0) return 0;
|
|
142
|
+
if (perM < 1) return 1;
|
|
143
|
+
if (perM < 5) return 2;
|
|
144
|
+
if (perM < 20) return 3;
|
|
145
|
+
return 4;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** One line for a picker, naming the trade rather than ranking the options. */
|
|
149
|
+
export function describeModel(m: EndpointModel): string {
|
|
150
|
+
// A published rate is the most useful thing anyone can be told about a
|
|
151
|
+
// model they are about to spend on, so it leads.
|
|
152
|
+
if (m.price) return rateLine(m.price);
|
|
153
|
+
if (m.remote) {
|
|
154
|
+
return `Runs on ${m.host ?? 'the provider’s servers'}, not this machine. ` +
|
|
155
|
+
'Fast; billed to that account, at a rate Foreman cannot see.';
|
|
156
|
+
}
|
|
157
|
+
return `Local${m.size ? ` · ${m.size}` : ''}. Free and private, but slow on long prompts.`;
|
|
158
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { expandHome, parseCommand, projectsRoot, slug } from './commands.js';
|
|
4
|
+
|
|
5
|
+
test('parseCommand: one shape per command, bot suffix tolerated, junk is null', () => {
|
|
6
|
+
assert.deepEqual(parseCommand('/help'), { cmd: 'help' });
|
|
7
|
+
assert.deepEqual(parseCommand('/start'), { cmd: 'help' });
|
|
8
|
+
assert.deepEqual(parseCommand('/projects'), { cmd: 'projects' });
|
|
9
|
+
assert.deepEqual(parseCommand('/new My Shop'), { cmd: 'new', name: 'My Shop' });
|
|
10
|
+
assert.deepEqual(parseCommand('/plan@ForemanBot test-4 add a footer\nwith links'), { cmd: 'plan', project: 'test-4', text: 'add a footer\nwith links' });
|
|
11
|
+
assert.deepEqual(parseCommand('/run lp1 ship it'), { cmd: 'run', project: 'lp1', text: 'ship it' });
|
|
12
|
+
assert.deepEqual(parseCommand('/stop'), { cmd: 'stop' });
|
|
13
|
+
assert.deepEqual(parseCommand('/stop lp1'), { cmd: 'stop', project: 'lp1' });
|
|
14
|
+
assert.equal(parseCommand('/plan test-4'), null);
|
|
15
|
+
assert.equal(parseCommand('/new'), null);
|
|
16
|
+
assert.equal(parseCommand('/dance'), null);
|
|
17
|
+
assert.equal(parseCommand('just words'), null);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('slug and roots', () => {
|
|
21
|
+
assert.equal(slug('My New App!'), 'my-new-app');
|
|
22
|
+
assert.equal(slug(' ../evil '), 'evil');
|
|
23
|
+
assert.equal(expandHome('~/Projects', '/home/a'), '/home/a/Projects');
|
|
24
|
+
assert.equal(projectsRoot(undefined, '/home/a'), '/home/a/Projects');
|
|
25
|
+
assert.equal(projectsRoot('~/Code', '/home/a'), '/home/a/Code');
|
|
26
|
+
assert.equal(projectsRoot('/srv/work', '/home/a'), '/srv/work');
|
|
27
|
+
assert.equal(projectsRoot('rel', '/home/a'), '/home/a/rel');
|
|
28
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the linked Telegram chat can say to Foreman besides answering asks.
|
|
3
|
+
*
|
|
4
|
+
* The parser is pure and small on purpose: the phone is the least forgiving
|
|
5
|
+
* place to discover a command's shape, so every command has one shape, a
|
|
6
|
+
* `/help` lists them, and anything that is not a command is treated as talk
|
|
7
|
+
* for the planner of the project last spoken to.
|
|
8
|
+
*/
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
export type Command =
|
|
13
|
+
| { cmd: 'help' }
|
|
14
|
+
| { cmd: 'projects' }
|
|
15
|
+
| { cmd: 'status' }
|
|
16
|
+
| { cmd: 'new'; name: string }
|
|
17
|
+
| { cmd: 'plan'; project: string; text: string }
|
|
18
|
+
| { cmd: 'run'; project: string; text: string }
|
|
19
|
+
| { cmd: 'stop'; project?: string };
|
|
20
|
+
|
|
21
|
+
/** `/plan@ForemanBot test-4 add a footer` → { cmd: 'plan', project: 'test-4', text: 'add a footer' }. */
|
|
22
|
+
export function parseCommand(text: string): Command | null {
|
|
23
|
+
const m = /^\/(\w+)(?:@\w+)?(?:\s+([\s\S]*))?$/.exec(text.trim());
|
|
24
|
+
if (!m) return null;
|
|
25
|
+
const cmd = m[1].toLowerCase();
|
|
26
|
+
const rest = (m[2] ?? '').trim();
|
|
27
|
+
const split = () => {
|
|
28
|
+
const i = rest.search(/\s/);
|
|
29
|
+
return i < 0 ? [rest, ''] : [rest.slice(0, i), rest.slice(i).trim()];
|
|
30
|
+
};
|
|
31
|
+
switch (cmd) {
|
|
32
|
+
case 'help': case 'start': return { cmd: 'help' };
|
|
33
|
+
case 'projects': case 'ls': return { cmd: 'projects' };
|
|
34
|
+
case 'status': return { cmd: 'status' };
|
|
35
|
+
case 'new': return rest ? { cmd: 'new', name: rest } : null;
|
|
36
|
+
case 'plan': { const [project, t] = split(); return project && t ? { cmd: 'plan', project, text: t } : null; }
|
|
37
|
+
case 'run': { const [project, t] = split(); return project && t ? { cmd: 'run', project, text: t } : null; }
|
|
38
|
+
case 'stop': return { cmd: 'stop', ...(rest ? { project: rest } : {}) };
|
|
39
|
+
default: return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A folder name from whatever was typed: `My New App!` → `my-new-app`. */
|
|
44
|
+
export function slug(name: string): string {
|
|
45
|
+
return name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 64);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `~/Projects` → `/Users/me/Projects`; absolute paths pass through. */
|
|
49
|
+
export function expandHome(p: string, home = os.homedir()): string {
|
|
50
|
+
if (p === '~') return home;
|
|
51
|
+
if (p.startsWith('~/')) return path.join(home, p.slice(2));
|
|
52
|
+
return p;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The projects root: Settings → Projects → "Projects root", else `~/Projects`. */
|
|
56
|
+
export function projectsRoot(settingsRoot: unknown, home = os.homedir()): string {
|
|
57
|
+
const v = typeof settingsRoot === 'string' && settingsRoot.trim() ? settingsRoot.trim() : '~/Projects';
|
|
58
|
+
const abs = expandHome(v, home);
|
|
59
|
+
return path.isAbsolute(abs) ? abs : path.join(home, abs);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const HELP_TEXT = [
|
|
63
|
+
'<b>Foreman</b> — what you can say here',
|
|
64
|
+
'',
|
|
65
|
+
'/projects — the fleet, with what is running',
|
|
66
|
+
'/status — the runs in flight and what they need',
|
|
67
|
+
'/new <name> — create a project under your projects root and link it',
|
|
68
|
+
'/plan <project> <what you want> — talk to that project\'s planner',
|
|
69
|
+
'/run <project> <brief> — skip the talk: start a mission at the project\'s default cap',
|
|
70
|
+
'/stop [project] — stop the planner reply in flight',
|
|
71
|
+
'',
|
|
72
|
+
'Anything else you type answers the open question, or continues the last planning conversation.',
|
|
73
|
+
].join('\n');
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram — the first channel, chosen because it needs no credential minted.
|
|
3
|
+
*
|
|
4
|
+
* The human creates a bot with @BotFather and pastes its token; Foreman keeps
|
|
5
|
+
* that in the secret store at 0600, like a provider key, and never returns
|
|
6
|
+
* it. Linking is a one-time code: Foreman shows `/start <code>` (and a QR of
|
|
7
|
+
* the t.me deep link that pre-fills it), the human sends it to their bot, and
|
|
8
|
+
* the chat that sent it is the one Foreman talks to. No OAuth, no public URL
|
|
9
|
+
* — updates are read by long-polling `getUpdates`, which works from a laptop
|
|
10
|
+
* behind NAT, where a webhook would not.
|
|
11
|
+
*
|
|
12
|
+
* ONE POLLER. Telegram allows a single `getUpdates` consumer per bot; two
|
|
13
|
+
* concurrent pollers get 409s and lose messages between them. So a bot has
|
|
14
|
+
* exactly one {@link TelegramBot}, which reads everything — link codes,
|
|
15
|
+
* button taps, text replies — and dispatches. Linking, answering and the
|
|
16
|
+
* transport all hang off it.
|
|
17
|
+
*
|
|
18
|
+
* Everything here swallows transport errors. A dead channel is a lost tap on
|
|
19
|
+
* the shoulder; it must never become a failed run.
|
|
20
|
+
*/
|
|
21
|
+
import type { Button, Transport } from '../notify.js';
|
|
22
|
+
|
|
23
|
+
export const TELEGRAM_API = 'https://api.telegram.org';
|
|
24
|
+
|
|
25
|
+
interface TgResponse<T> { ok: boolean; result?: T; description?: string }
|
|
26
|
+
|
|
27
|
+
async function call<T>(
|
|
28
|
+
apiBase: string, token: string, method: string, body: Record<string, unknown>, timeoutMs = 15_000,
|
|
29
|
+
): Promise<T | null> {
|
|
30
|
+
try {
|
|
31
|
+
const r = await fetch(`${apiBase}/bot${token}/${method}`, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'content-type': 'application/json' },
|
|
34
|
+
body: JSON.stringify(body),
|
|
35
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
36
|
+
});
|
|
37
|
+
const j = await r.json().catch(() => null) as TgResponse<T> | null;
|
|
38
|
+
return j?.ok ? (j.result as T) : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The bot's own identity, used to validate a pasted token and to show whose bot this is. */
|
|
45
|
+
export async function getMe(token: string, apiBase = TELEGRAM_API): Promise<{ username: string } | null> {
|
|
46
|
+
const me = await call<{ username?: string }>(apiBase, token, 'getMe', {}, 8_000);
|
|
47
|
+
return me?.username ? { username: me.username } : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const keyboard = (buttons?: Button[][]) => buttons?.length
|
|
51
|
+
? { reply_markup: { inline_keyboard: buttons.map((row) => row.map((b) => ({ text: b.label, callback_data: b.data.slice(0, 64) }))) } }
|
|
52
|
+
: {};
|
|
53
|
+
|
|
54
|
+
/** A Telegram chat as a Transport. HTML parse mode; previews off so links stay one line. */
|
|
55
|
+
export function telegramTransport(token: string, chatId: string, apiBase = TELEGRAM_API): Transport {
|
|
56
|
+
return {
|
|
57
|
+
name: 'telegram',
|
|
58
|
+
async send(text, opts) {
|
|
59
|
+
const m = await call<{ message_id: number }>(apiBase, token, 'sendMessage', {
|
|
60
|
+
chat_id: chatId, text, parse_mode: 'HTML', disable_web_page_preview: true, ...keyboard(opts?.buttons),
|
|
61
|
+
});
|
|
62
|
+
return m ? String(m.message_id) : null;
|
|
63
|
+
},
|
|
64
|
+
async edit(id, text, opts) {
|
|
65
|
+
// No reply_markup in the body means Telegram removes the keyboard — an
|
|
66
|
+
// answered question offers nothing to tap.
|
|
67
|
+
await call(apiBase, token, 'editMessageText', {
|
|
68
|
+
chat_id: chatId, message_id: Number(id), text, parse_mode: 'HTML', disable_web_page_preview: true,
|
|
69
|
+
...(opts?.buttons ? keyboard(opts.buttons) : { reply_markup: { inline_keyboard: [] } }),
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The deep link that opens the bot with `/start <code>` pre-filled.
|
|
77
|
+
*
|
|
78
|
+
* Telegram delivers the `start` payload as exactly the message the poller
|
|
79
|
+
* waits for, so a QR of this URL turns linking into one scan and one tap —
|
|
80
|
+
* no typing a code on a phone. Only the bot's username is public here; the
|
|
81
|
+
* code is single-use and expires, which is why it is safe on a screen.
|
|
82
|
+
*/
|
|
83
|
+
/** The "/" menu on the phone. Registered on every start, so the list is never behind the code. */
|
|
84
|
+
export const BOT_COMMANDS: Array<{ command: string; description: string }> = [
|
|
85
|
+
{ command: 'projects', description: 'The fleet, with what is running' },
|
|
86
|
+
{ command: 'status', description: 'Runs in flight, spend, what needs you' },
|
|
87
|
+
{ command: 'new', description: 'Create a project: /new <name>' },
|
|
88
|
+
{ command: 'plan', description: 'Talk to a planner: /plan <project> <what you want>' },
|
|
89
|
+
{ command: 'run', description: 'Skip the talk: /run <project> <brief>' },
|
|
90
|
+
{ command: 'stop', description: 'Stop the planner reply in flight' },
|
|
91
|
+
{ command: 'help', description: 'What you can say here' },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
export async function setBotCommands(token: string, apiBase = TELEGRAM_API): Promise<boolean> {
|
|
95
|
+
try { await call(apiBase, token, 'setMyCommands', { commands: BOT_COMMANDS }, 10_000); return true; }
|
|
96
|
+
catch { return false; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function telegramStartLink(bot: string, code: string): string {
|
|
100
|
+
return `https://t.me/${bot.replace(/^@/, '')}?start=${encodeURIComponent(code)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Six characters from an alphabet with no look-alikes: typed on a phone, read off a screen. */
|
|
104
|
+
export function linkCode(): string {
|
|
105
|
+
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
106
|
+
let out = '';
|
|
107
|
+
for (let i = 0; i < 6; i++) out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface LinkedChat {
|
|
112
|
+
chatId: string;
|
|
113
|
+
/** Who linked: a username or first name, for the Settings panel to show. */
|
|
114
|
+
label: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface TgUpdate {
|
|
118
|
+
update_id: number;
|
|
119
|
+
message?: {
|
|
120
|
+
message_id: number; text?: string;
|
|
121
|
+
chat: { id: number; username?: string; first_name?: string; title?: string };
|
|
122
|
+
reply_to_message?: { message_id: number };
|
|
123
|
+
};
|
|
124
|
+
callback_query?: {
|
|
125
|
+
id: string; data?: string;
|
|
126
|
+
from: { id: number };
|
|
127
|
+
message?: { message_id: number; chat: { id: number } };
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface BotHandlers {
|
|
132
|
+
/** A tap on an inline button, from the linked chat only. */
|
|
133
|
+
onCallback?: (data: string, messageId: string) => void;
|
|
134
|
+
/** A text message, from the linked chat only. */
|
|
135
|
+
onText?: (text: string, replyToMessageId?: string) => void;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The one reader of a bot's updates.
|
|
140
|
+
*
|
|
141
|
+
* `linkedChatId` is the only chat whose taps and texts are dispatched; a
|
|
142
|
+
* message from anyone else is dropped unless it is a `/start <code>` for a
|
|
143
|
+
* code currently being waited on — the only way in, and it needs the code
|
|
144
|
+
* shown on the human's own screen.
|
|
145
|
+
*/
|
|
146
|
+
export class TelegramBot {
|
|
147
|
+
private stopped = false;
|
|
148
|
+
private offset: number | undefined;
|
|
149
|
+
private links = new Map<string, { resolve: (c: LinkedChat) => void; deadline: number }>();
|
|
150
|
+
private running: Promise<void> | null = null;
|
|
151
|
+
|
|
152
|
+
constructor(
|
|
153
|
+
private readonly token: string,
|
|
154
|
+
private readonly apiBase = TELEGRAM_API,
|
|
155
|
+
public linkedChatId: string | null = null,
|
|
156
|
+
private handlers: BotHandlers = {},
|
|
157
|
+
) {}
|
|
158
|
+
|
|
159
|
+
setHandlers(h: BotHandlers): void { this.handlers = h; }
|
|
160
|
+
|
|
161
|
+
/** Begin polling; idempotent. */
|
|
162
|
+
start(): void {
|
|
163
|
+
if (this.running) return;
|
|
164
|
+
this.stopped = false;
|
|
165
|
+
this.running = this.loop().finally(() => { this.running = null; });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Stop reading, and tell Telegram what was consumed. Without the final
|
|
170
|
+
* zero-timeout `getUpdates` carrying the advanced offset, the next reader
|
|
171
|
+
* on this token would be handed the same updates again — a link code that
|
|
172
|
+
* already linked, a tap that already resolved.
|
|
173
|
+
*/
|
|
174
|
+
async stop(): Promise<void> {
|
|
175
|
+
this.stopped = true;
|
|
176
|
+
if (this.offset !== undefined) {
|
|
177
|
+
await call(this.apiBase, this.token, 'getUpdates', { offset: this.offset, timeout: 0 }, 5_000);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Wait for `/start <code>`; resolves with the chat that sent it, or null at
|
|
183
|
+
* the deadline. Several codes may wait at once; the newest one from the
|
|
184
|
+
* Settings panel is the one the human sees, and an old code someone else
|
|
185
|
+
* happened to have still only links if it has not expired.
|
|
186
|
+
*/
|
|
187
|
+
link(code: string, maxMs = 10 * 60_000): { done: Promise<LinkedChat | null>; abort(): void } {
|
|
188
|
+
let settle: (c: LinkedChat | null) => void = () => {};
|
|
189
|
+
const done = new Promise<LinkedChat | null>((r) => { settle = r; });
|
|
190
|
+
const key = code.toLowerCase();
|
|
191
|
+
this.links.set(key, { resolve: (c) => { this.links.delete(key); settle(c); }, deadline: Date.now() + maxMs });
|
|
192
|
+
const timer = setTimeout(() => { if (this.links.delete(key)) settle(null); }, maxMs);
|
|
193
|
+
(timer as { unref?: () => void }).unref?.();
|
|
194
|
+
this.start();
|
|
195
|
+
return { done, abort: () => { if (this.links.delete(key)) settle(null); clearTimeout(timer); } };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async loop(): Promise<void> {
|
|
199
|
+
while (!this.stopped) {
|
|
200
|
+
const updates = await call<TgUpdate[]>(this.apiBase, this.token, 'getUpdates', {
|
|
201
|
+
timeout: 25, offset: this.offset, allowed_updates: ['message', 'callback_query'],
|
|
202
|
+
}, 35_000);
|
|
203
|
+
if (this.stopped) return;
|
|
204
|
+
if (!updates) { await new Promise((r) => setTimeout(r, 2_000)); continue; }
|
|
205
|
+
for (const u of updates) {
|
|
206
|
+
this.offset = u.update_id + 1;
|
|
207
|
+
try { await this.dispatch(u); } catch { /* one bad update must not stop the reader */ }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private async dispatch(u: TgUpdate): Promise<void> {
|
|
213
|
+
if (u.callback_query) {
|
|
214
|
+
const cq = u.callback_query;
|
|
215
|
+
const chat = String(cq.message?.chat.id ?? cq.from.id);
|
|
216
|
+
// Always acknowledge, so the phone's spinner stops — even for a tap we
|
|
217
|
+
// then ignore because it came from a chat that is not the linked one.
|
|
218
|
+
await call(this.apiBase, this.token, 'answerCallbackQuery', { callback_query_id: cq.id }, 5_000);
|
|
219
|
+
if (this.linkedChatId && chat === this.linkedChatId && cq.data) {
|
|
220
|
+
this.handlers.onCallback?.(cq.data, String(cq.message?.message_id ?? ''));
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const m = u.message;
|
|
225
|
+
if (!m) return;
|
|
226
|
+
const chat = String(m.chat.id);
|
|
227
|
+
const text = (m.text ?? '').trim();
|
|
228
|
+
|
|
229
|
+
const start = /^\/start(?:@\w+)?\s+(\S+)/i.exec(text);
|
|
230
|
+
if (start) {
|
|
231
|
+
const want = this.links.get(start[1].toLowerCase());
|
|
232
|
+
if (want && want.deadline > Date.now()) {
|
|
233
|
+
this.linkedChatId = chat;
|
|
234
|
+
want.resolve({ chatId: chat, label: m.chat.username ? `@${m.chat.username}` : (m.chat.first_name ?? m.chat.title ?? chat) });
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (this.linkedChatId && chat === this.linkedChatId && text) {
|
|
239
|
+
this.handlers.onText?.(text, m.reply_to_message ? String(m.reply_to_message.message_id) : undefined);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Compatibility: wait for a link code with a poller of its own. Only for a
|
|
246
|
+
* bot that has no running {@link TelegramBot} — two pollers on one token
|
|
247
|
+
* conflict. The server uses `bot.link(code)` instead.
|
|
248
|
+
*/
|
|
249
|
+
export function linkByCode(
|
|
250
|
+
token: string, code: string,
|
|
251
|
+
opts: { apiBase?: string; maxMs?: number } = {},
|
|
252
|
+
): { done: Promise<LinkedChat | null>; abort(): void } {
|
|
253
|
+
const bot = new TelegramBot(token, opts.apiBase ?? TELEGRAM_API);
|
|
254
|
+
const l = bot.link(code, opts.maxMs);
|
|
255
|
+
// The ack is awaited before `done` resolves, so a caller that starts a new
|
|
256
|
+
// reader right after is not handed the code message again.
|
|
257
|
+
const done = l.done.then(async (c) => { await bot.stop(); return c; });
|
|
258
|
+
return { done, abort: () => { l.abort(); void bot.stop(); } };
|
|
259
|
+
}
|