aibroker 0.13.0 → 0.14.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.
Files changed (64) hide show
  1. package/dist/adapters/iterm/core.d.ts +11 -0
  2. package/dist/adapters/iterm/core.d.ts.map +1 -1
  3. package/dist/adapters/iterm/core.js +38 -16
  4. package/dist/adapters/iterm/core.js.map +1 -1
  5. package/dist/core/env.d.ts +14 -0
  6. package/dist/core/env.d.ts.map +1 -0
  7. package/dist/core/env.js +48 -0
  8. package/dist/core/env.js.map +1 -0
  9. package/dist/core/state.d.ts +27 -1
  10. package/dist/core/state.d.ts.map +1 -1
  11. package/dist/core/state.js +20 -1
  12. package/dist/core/state.js.map +1 -1
  13. package/dist/daemon/cli.js +6 -0
  14. package/dist/daemon/cli.js.map +1 -1
  15. package/dist/daemon/commands.d.ts.map +1 -1
  16. package/dist/daemon/commands.js +31 -10
  17. package/dist/daemon/commands.js.map +1 -1
  18. package/dist/daemon/core-handlers.d.ts.map +1 -1
  19. package/dist/daemon/core-handlers.js +120 -12
  20. package/dist/daemon/core-handlers.js.map +1 -1
  21. package/dist/daemon/dispatch.d.ts +8 -0
  22. package/dist/daemon/dispatch.d.ts.map +1 -1
  23. package/dist/daemon/dispatch.js +20 -3
  24. package/dist/daemon/dispatch.js.map +1 -1
  25. package/dist/daemon/index.d.ts.map +1 -1
  26. package/dist/daemon/index.js +32 -34
  27. package/dist/daemon/index.js.map +1 -1
  28. package/dist/daemon/mailbox-watch.d.ts +26 -0
  29. package/dist/daemon/mailbox-watch.d.ts.map +1 -0
  30. package/dist/daemon/mailbox-watch.js +69 -0
  31. package/dist/daemon/mailbox-watch.js.map +1 -0
  32. package/dist/daemon/ota.d.ts.map +1 -1
  33. package/dist/daemon/ota.js +9 -3
  34. package/dist/daemon/ota.js.map +1 -1
  35. package/dist/daemon/todoist-cli.d.ts +10 -0
  36. package/dist/daemon/todoist-cli.d.ts.map +1 -0
  37. package/dist/daemon/todoist-cli.js +108 -0
  38. package/dist/daemon/todoist-cli.js.map +1 -0
  39. package/dist/daemon/todoist-ingress.d.ts +47 -0
  40. package/dist/daemon/todoist-ingress.d.ts.map +1 -0
  41. package/dist/daemon/todoist-ingress.js +101 -0
  42. package/dist/daemon/todoist-ingress.js.map +1 -0
  43. package/dist/daemon/todoist-oauth.d.ts +69 -0
  44. package/dist/daemon/todoist-oauth.d.ts.map +1 -0
  45. package/dist/daemon/todoist-oauth.js +182 -0
  46. package/dist/daemon/todoist-oauth.js.map +1 -0
  47. package/dist/daemon/todoist-owners.d.ts +23 -0
  48. package/dist/daemon/todoist-owners.d.ts.map +1 -0
  49. package/dist/daemon/todoist-owners.js +60 -0
  50. package/dist/daemon/todoist-owners.js.map +1 -0
  51. package/dist/daemon/todoist-reply.d.ts +58 -0
  52. package/dist/daemon/todoist-reply.d.ts.map +1 -0
  53. package/dist/daemon/todoist-reply.js +135 -0
  54. package/dist/daemon/todoist-reply.js.map +1 -0
  55. package/dist/daemon/todoist-webhook.d.ts +66 -3
  56. package/dist/daemon/todoist-webhook.d.ts.map +1 -1
  57. package/dist/daemon/todoist-webhook.js +244 -17
  58. package/dist/daemon/todoist-webhook.js.map +1 -1
  59. package/dist/mcp/index.js +30 -2
  60. package/dist/mcp/index.js.map +1 -1
  61. package/dist/transport/sync-facade.d.ts.map +1 -1
  62. package/dist/transport/sync-facade.js +12 -0
  63. package/dist/transport/sync-facade.js.map +1 -1
  64. package/package.json +1 -1
@@ -0,0 +1,135 @@
1
+ /**
2
+ * daemon/todoist-reply.ts — answering on the task you were asked from.
3
+ *
4
+ * A task arriving here is often a question, not a work order, and until now the
5
+ * answer went to a terminal the asker was not looking at. Filing from a watch
6
+ * and reading the reply at a desk is not a channel; it is two half channels.
7
+ *
8
+ * So a session answers on the task itself. The comment carries the agent mark,
9
+ * which `route()` already drops before anything else, so our own writes cannot
10
+ * come back as instructions.
11
+ *
12
+ * Deliberately does NOT complete the task. Completion is the human saying done
13
+ * — the same rule the receiver enforces on the way in — and a question whose
14
+ * answer you have not read yet is not done. Left open, the task keeps its place
15
+ * in the list with a comment indicator; completed, it vanishes from view and
16
+ * the answer with it.
17
+ */
18
+ import { AGENT_MARK } from "./todoist-webhook.js";
19
+ import { loadToken } from "./todoist-oauth.js";
20
+ import { log } from "../core/log.js";
21
+ import { audit } from "./audit.js";
22
+ /**
23
+ * Fetch the task a comment belongs to.
24
+ *
25
+ * A `note:added` payload carries `item_id` and the comment text and nothing
26
+ * else — no project, no labels. Without them the security boundary cannot be
27
+ * evaluated, so a comment is unroutable until its parent is resolved. This is
28
+ * the lookup that makes replying-by-comment possible at all.
29
+ */
30
+ export async function fetchParentTask(taskId, fetchImpl = fetch) {
31
+ const token = loadToken();
32
+ if (!token)
33
+ throw new Error("no Todoist authorisation on file — run `aibroker todoist auth`");
34
+ const res = await fetchImpl(`https://api.todoist.com/api/v1/tasks/${encodeURIComponent(taskId)}`, {
35
+ headers: { authorization: `${token.token_type} ${token.access_token}` },
36
+ });
37
+ const raw = await res.text();
38
+ if (!res.ok)
39
+ throw new Error(`task lookup failed with ${res.status}: ${raw.slice(0, 200)}`);
40
+ let t;
41
+ try {
42
+ t = JSON.parse(raw);
43
+ }
44
+ catch {
45
+ throw new Error(`task lookup returned unparseable body: ${raw.slice(0, 200)}`);
46
+ }
47
+ return {
48
+ content: t.content ?? "",
49
+ projectId: t.project_id ?? "",
50
+ labels: Array.isArray(t.labels) ? t.labels.map(String) : [],
51
+ };
52
+ }
53
+ /**
54
+ * Post a comment on a task as the agent.
55
+ *
56
+ * The mark is added here rather than trusted to callers: every path that can
57
+ * write to Todoist has to be echo-safe, and "remember the prefix" is not a
58
+ * safety mechanism.
59
+ */
60
+ export async function replyToTask(taskId, text, fetchImpl = fetch) {
61
+ const token = loadToken();
62
+ if (!token) {
63
+ throw new Error("no Todoist authorisation on file — run `aibroker todoist auth`");
64
+ }
65
+ const body = text.trimStart().startsWith(AGENT_MARK) ? text : `${AGENT_MARK} ${text}`;
66
+ const res = await fetchImpl("https://api.todoist.com/api/v1/comments", {
67
+ method: "POST",
68
+ headers: {
69
+ "content-type": "application/json",
70
+ authorization: `${token.token_type} ${token.access_token}`,
71
+ },
72
+ body: JSON.stringify({ task_id: taskId, content: body }),
73
+ });
74
+ const raw = await res.text();
75
+ if (!res.ok)
76
+ throw new Error(`comment failed with ${res.status}: ${raw.slice(0, 200)}`);
77
+ let parsed;
78
+ try {
79
+ parsed = JSON.parse(raw);
80
+ }
81
+ catch {
82
+ throw new Error(`comment returned unparseable body: ${raw.slice(0, 200)}`);
83
+ }
84
+ const result = { commentId: parsed.id ?? "?", taskId };
85
+ audit({
86
+ action: "todoist-reply", actor: "aibroker", target: `todoist:task:${taskId}`,
87
+ outcome: "posted", body,
88
+ });
89
+ log(`todoist-reply: commented on ${taskId}`);
90
+ return result;
91
+ }
92
+ /** Normalise a title the way a human comparing two list rows would. */
93
+ function sameTitle(a, b) {
94
+ const n = (s) => s.trim().toLowerCase().replace(/\s+/g, " ");
95
+ return n(a) === n(b);
96
+ }
97
+ /**
98
+ * How many OPEN tasks in this project share a title.
99
+ *
100
+ * Two tasks called the same thing are indistinguishable in a list, so a reply
101
+ * posted on one looks — to whoever is watching the other — exactly like being
102
+ * ignored. That cost two full rounds today: comment routing was working
103
+ * perfectly and the answers were landing on a sibling nobody was looking at.
104
+ * Being ignored and being answered elsewhere must not look the same.
105
+ *
106
+ * Returns 1 in the ordinary case (this task alone) and 0 when it cannot tell —
107
+ * a lookup failure must not invent a duplicate warning.
108
+ */
109
+ export async function countTasksWithTitle(projectId, title, fetchImpl = fetch) {
110
+ const token = loadToken();
111
+ if (!token || !projectId || !title.trim())
112
+ return 0;
113
+ const res = await fetchImpl(`https://api.todoist.com/api/v1/tasks?project_id=${encodeURIComponent(projectId)}`, { headers: { authorization: `${token.token_type} ${token.access_token}` } });
114
+ if (!res.ok)
115
+ return 0;
116
+ let body;
117
+ try {
118
+ body = JSON.parse(await res.text());
119
+ }
120
+ catch {
121
+ return 0;
122
+ }
123
+ // This endpoint has returned both a bare array and a {results:[…]} envelope
124
+ // across versions; accept either rather than silently counting zero.
125
+ const list = Array.isArray(body)
126
+ ? body
127
+ : Array.isArray(body?.results)
128
+ ? body.results
129
+ : [];
130
+ return list.filter((t) => {
131
+ const c = t?.content;
132
+ return typeof c === "string" && sameTitle(c, title);
133
+ }).length;
134
+ }
135
+ //# sourceMappingURL=todoist-reply.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"todoist-reply.js","sourceRoot":"","sources":["../../src/daemon/todoist-reply.ts"],"names":[],"mappings":"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"}
@@ -65,6 +65,21 @@ export interface WebhookConfig {
65
65
  bind: string;
66
66
  /** Path the receiver answers on. Anything else 404s. */
67
67
  path: string;
68
+ /**
69
+ * Client id from the App Management Console.
70
+ *
71
+ * Only needed for the OAuth landing below — the webhook path itself is
72
+ * verified by HMAC and never uses it.
73
+ */
74
+ clientId?: string;
75
+ /**
76
+ * Path the OAuth redirect lands on.
77
+ *
78
+ * Todoist rejects a redirect URL carrying a port, so this one cannot be
79
+ * reached over the same Funnel as the webhook. Put a tailnet-only Serve on
80
+ * 443 in front of it — see docs/todoist.md.
81
+ */
82
+ oauthPath: string;
68
83
  /**
69
84
  * Project ids allowed to reach a session. An explicit allowlist, not a
70
85
  * subtree resolved at runtime, and deliberately so: a project added later
@@ -80,14 +95,35 @@ export interface WebhookConfig {
80
95
  */
81
96
  defaultOwner?: string;
82
97
  }
98
+ /** Which rule chose the owner. Recorded, so a wrong guess is visible. */
99
+ export type OwnerRule = "label" | "address" | "held" | "project" | "default";
83
100
  export type RouteDecision = {
84
101
  act: true;
85
102
  project: string;
86
103
  body: string;
87
104
  taskId: string;
105
+ rule: OwnerRule;
106
+ nearMiss?: string;
88
107
  } | {
89
108
  act: false;
90
109
  reason: string;
110
+ nearMiss?: string;
111
+ };
112
+ /**
113
+ * Pull a leading session name off the content.
114
+ *
115
+ * Typing a label is several taps on a phone and worse on a watch, so the
116
+ * cheapest possible address is the first word: "pai send a whatsapp message".
117
+ * Only names we already know are accepted — otherwise "home improvements"
118
+ * becomes a work order for the Home session — and an explicit `name:` or
119
+ * `name,` counts as addressing even when the name is unknown, so that a typo
120
+ * can be reported instead of silently ignored.
121
+ */
122
+ export declare function parseAddress(content: string, known: Set<string>): {
123
+ owner?: string;
124
+ rest: string;
125
+ firstToken: string | null;
126
+ addressed: boolean;
91
127
  };
92
128
  /**
93
129
  * Verify Todoist's HMAC over the RAW body.
@@ -104,14 +140,41 @@ export declare function eventKey(e: TodoistEvent): string;
104
140
  * Pure, so the routing rules are testable without a socket: this function is
105
141
  * the entire security boundary of the ingress.
106
142
  */
107
- export declare function route(e: TodoistEvent, cfg: WebhookConfig): RouteDecision;
143
+ export declare function route(e: TodoistEvent, cfg: WebhookConfig, knownOwners?: Iterable<string>,
144
+ /**
145
+ * The session already holding this task, when we know it.
146
+ *
147
+ * Only meaningful for a comment: a follow-up belongs to whoever did the work,
148
+ * not to whatever the project mapping would pick today.
149
+ */
150
+ heldBy?: string): RouteDecision;
108
151
  export interface WebhookDeps {
109
- /** Deliver a work order. Returns a short outcome for the audit record. */
110
- deliver: (project: string, body: string) => Promise<{
152
+ /**
153
+ * Deliver a work order. Returns a short outcome for the audit record.
154
+ *
155
+ * `prefix` distinguishes a correction from a new work order: `[Task]` reads
156
+ * as "start this", and a session that receives a comment under that heading
157
+ * will begin again rather than adjust.
158
+ */
159
+ deliver: (project: string, body: string, opts?: {
160
+ prefix?: string;
161
+ }) => Promise<{
111
162
  outcome: string;
112
163
  session: string;
113
164
  reason?: string;
114
165
  }>;
166
+ /**
167
+ * Names that may be used as an address in a task's first word.
168
+ *
169
+ * Must be the names DISPATCH can resolve, not merely the ones a human would
170
+ * recognise. Addressing a live session that dispatch has no alias for reads
171
+ * as success and delivers nothing — which is exactly how "paicloud send me a
172
+ * mail" was quietly answered by the wrong session.
173
+ *
174
+ * A function rather than a list: aliases and sessions both change while the
175
+ * daemon runs, and a name that was valid at boot need not be valid now.
176
+ */
177
+ knownOwners?: () => string[] | Promise<string[]>;
115
178
  }
116
179
  export declare function createWebhookServer(cfg: WebhookConfig, deps: WebhookDeps): Server;
117
180
  /** Read config from the environment. Returns null when not configured. */
@@ -1 +1 @@
1
- {"version":3,"file":"todoist-webhook.d.ts","sourceRoot":"","sources":["../../src/daemon/todoist-webhook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,EAA2D,KAAK,MAAM,EAAE,MAAM,WAAW,CAAC;AAKjG,qFAAqF;AACrF,eAAO,MAAM,UAAU,iBAAO,CAAC;AAE/B,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC3C,SAAS,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,EAAE,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAChE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;;;OASG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC/B,mFAAmF;IACnF,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,MAAM,aAAa,GACrB;IAAE,GAAG,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC5D;IAAE,GAAG,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEnC;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAOhG;AAED,sEAAsE;AACtE,wBAAgB,QAAQ,CAAC,CAAC,EAAE,YAAY,GAAG,MAAM,CAGhD;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,CAAC,EAAE,YAAY,EAAE,GAAG,EAAE,aAAa,GAAG,aAAa,CAkDxE;AAmBD,MAAM,WAAW,WAAW;IAC1B,0EAA0E;IAC1E,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC5G;AAgBD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,aAAa,EAAE,IAAI,EAAE,WAAW,GAAG,MAAM,CA+EjF;AAED,0EAA0E;AAC1E,wBAAgB,oBAAoB,IAAI,aAAa,GAAG,IAAI,CAuB3D;AAED,yDAAyD;AACzD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CAwBpE"}
1
+ {"version":3,"file":"todoist-webhook.d.ts","sourceRoot":"","sources":["../../src/daemon/todoist-webhook.ts"],"names":[],"mappings":"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"}
@@ -36,8 +36,74 @@ import { createServer } from "node:http";
36
36
  import { createHmac, timingSafeEqual } from "node:crypto";
37
37
  import { log } from "../core/log.js";
38
38
  import { audit } from "./audit.js";
39
+ import { handleOAuthCallback } from "./todoist-oauth.js";
39
40
  /** Prefix on anything an agent writes back to Todoist, so we ignore our own echo. */
40
41
  export const AGENT_MARK = "🤖";
42
+ /**
43
+ * Something that looked like an address but was not one.
44
+ *
45
+ * A task saying `PAI do the thing` or labelled `pai` reads, to a human, as an
46
+ * instruction about where it should go. Neither parses, and the silent outcome
47
+ * is that it lands on the default owner as though nothing had been asked for.
48
+ * That is the failure this project keeps having to fix: a suppression nobody
49
+ * counts is indistinguishable from an event that never happened. So we say it.
50
+ */
51
+ function detectNearMiss(labels, firstToken, addressed, known) {
52
+ const labelAttempt = labels.find((l) => {
53
+ const low = l.toLowerCase();
54
+ // A label that already routes is not a near miss, whichever form it took.
55
+ if (low.startsWith("pai:") || known.has(low))
56
+ return false;
57
+ return low.startsWith("pai");
58
+ });
59
+ if (labelAttempt)
60
+ return `label "${labelAttempt}" names nobody — use a session name, or pai:<name>`;
61
+ if (addressed && firstToken && !known.has(firstToken.toLowerCase())) {
62
+ return `"${firstToken}" reads as an address but no session by that name is known`;
63
+ }
64
+ return undefined;
65
+ }
66
+ /**
67
+ * Owner named by a label, in either accepted form.
68
+ *
69
+ * `pai:<name>` is explicit and works even for a name we have never heard of.
70
+ * A bare `<name>` label is the cheap form — one tap in Todoist's picker — and
71
+ * is accepted only when it matches something dispatch can actually resolve,
72
+ * so an ordinary label like `urgent` stays an ordinary label.
73
+ */
74
+ function ownerFromLabels(labels, known) {
75
+ for (const raw of labels.map((l) => l.trim())) {
76
+ const low = raw.toLowerCase();
77
+ if (low.startsWith("pai:")) {
78
+ const name = raw.slice(4).trim();
79
+ if (name)
80
+ return name;
81
+ }
82
+ if (known.has(low))
83
+ return low;
84
+ }
85
+ return undefined;
86
+ }
87
+ /**
88
+ * Pull a leading session name off the content.
89
+ *
90
+ * Typing a label is several taps on a phone and worse on a watch, so the
91
+ * cheapest possible address is the first word: "pai send a whatsapp message".
92
+ * Only names we already know are accepted — otherwise "home improvements"
93
+ * becomes a work order for the Home session — and an explicit `name:` or
94
+ * `name,` counts as addressing even when the name is unknown, so that a typo
95
+ * can be reported instead of silently ignored.
96
+ */
97
+ export function parseAddress(content, known) {
98
+ const m = /^([\p{L}\p{N}_-]+)([:,])?\s+([\s\S]+)$/u.exec(content.trim());
99
+ if (!m)
100
+ return { rest: content, firstToken: null, addressed: false };
101
+ const [, token, punct, rest] = m;
102
+ const addressed = Boolean(punct);
103
+ if (known.has(token.toLowerCase()))
104
+ return { owner: token.toLowerCase(), rest, firstToken: token, addressed: true };
105
+ return { rest: content, firstToken: token, addressed };
106
+ }
41
107
  /**
42
108
  * Verify Todoist's HMAC over the RAW body.
43
109
  *
@@ -70,7 +136,26 @@ export function eventKey(e) {
70
136
  * Pure, so the routing rules are testable without a socket: this function is
71
137
  * the entire security boundary of the ingress.
72
138
  */
73
- export function route(e, cfg) {
139
+ export function route(e, cfg, knownOwners = [],
140
+ /**
141
+ * The session already holding this task, when we know it.
142
+ *
143
+ * Only meaningful for a comment: a follow-up belongs to whoever did the work,
144
+ * not to whatever the project mapping would pick today.
145
+ */
146
+ heldBy) {
147
+ // Names we will accept as an address: every session the hub can see, plus
148
+ // every owner named in config. Config counts even when the session is not
149
+ // running, so "clickr do x" is understood and reported as undeliverable
150
+ // rather than being read as the first word of a shopping list.
151
+ const known = new Set();
152
+ for (const n of knownOwners)
153
+ if (n)
154
+ known.add(n.toLowerCase());
155
+ for (const o of cfg.projectOwners.values())
156
+ known.add(o.toLowerCase());
157
+ if (cfg.defaultOwner)
158
+ known.add(cfg.defaultOwner.toLowerCase());
74
159
  const data = e.event_data ?? {};
75
160
  const content = typeof data.content === "string" ? data.content : "";
76
161
  const description = typeof data.description === "string" ? data.description : "";
@@ -80,7 +165,7 @@ export function route(e, cfg) {
80
165
  if (content.startsWith(AGENT_MARK) || description.startsWith(AGENT_MARK)) {
81
166
  return { act: false, reason: "agent-authored content, ignored to avoid an echo loop" };
82
167
  }
83
- const ACTIONABLE = new Set(["item:added", "item:completed", "reminder:fired"]);
168
+ const ACTIONABLE = new Set(["item:added", "item:completed", "reminder:fired", "note:added"]);
84
169
  if (!ACTIONABLE.has(e.event_name)) {
85
170
  return { act: false, reason: `event ${e.event_name} is not actionable` };
86
171
  }
@@ -98,21 +183,52 @@ export function route(e, cfg) {
98
183
  return { act: false, reason: "task has no content" };
99
184
  }
100
185
  // Owner, most explicit first:
101
- // 1. a `pai:<name>` label — say exactly where it goes
102
- // 2. the project it was filed in — "put it in the Whazaa list"
103
- // 3. the configured default — the watch case: Inbox, no label, no project
186
+ // 1. a `pai:<name>` label — say exactly where it goes
187
+ // 2. a leading name in the text — "pai send a whatsapp message"
188
+ // 3. the session already holding it — a comment follows its work
189
+ // 4. the project it was filed in — "put it in the Whazaa list"
190
+ // 5. the configured default — the watch case: Inbox, no label, no text address
191
+ //
192
+ // Text beats project: what you wrote is more deliberate than where it landed,
193
+ // and on a watch the project is whatever the quick-capture button chose.
104
194
  const labels = Array.isArray(data.labels) ? data.labels.map(String) : [];
105
- const labelled = labels.map((l) => l.trim())
106
- .find((l) => l.toLowerCase().startsWith("pai:"))?.slice(4).trim();
107
- const owner = labelled || cfg.projectOwners.get(projectId) || cfg.defaultOwner;
195
+ const labelled = ownerFromLabels(labels, known);
196
+ const addr = parseAddress(content, known);
197
+ const nearMiss = detectNearMiss(labels, addr.firstToken, addr.addressed, known);
198
+ let owner;
199
+ let rule;
200
+ if (labelled) {
201
+ owner = labelled;
202
+ rule = "label";
203
+ }
204
+ else if (addr.owner) {
205
+ owner = addr.owner;
206
+ rule = "address";
207
+ }
208
+ else if (heldBy) {
209
+ owner = heldBy;
210
+ rule = "held";
211
+ }
212
+ else if (cfg.projectOwners.get(projectId)) {
213
+ owner = cfg.projectOwners.get(projectId);
214
+ rule = "project";
215
+ }
216
+ else {
217
+ owner = cfg.defaultOwner;
218
+ rule = "default";
219
+ }
108
220
  if (!owner) {
109
221
  return {
110
222
  act: false,
111
- reason: "no pai:<name> label, no owner for this project, and no default owner configured",
223
+ reason: "no pai:<name> label, no name in the text, no owner for this project, and no default owner configured",
224
+ nearMiss,
112
225
  };
113
226
  }
114
- const body = description.trim() ? `${content}\n\n${description}` : content;
115
- return { act: true, project: owner, body, taskId };
227
+ // Only the address rule consumed part of the title; every other rule leaves
228
+ // the text exactly as written.
229
+ const title = rule === "address" ? addr.rest : content;
230
+ const body = description.trim() ? `${title}\n\n${description}` : title;
231
+ return { act: true, project: owner, body, taskId, rule, nearMiss };
116
232
  }
117
233
  /** Bounded replay guard: Todoist retries, and a retry must not run work twice. */
118
234
  class SeenSet {
@@ -156,12 +272,22 @@ export function createWebhookServer(cfg, deps) {
156
272
  const seen = new SeenSet();
157
273
  return createServer((req, res) => {
158
274
  void (async () => {
275
+ // Ignore query strings on the webhook path; a proxy may append them. The
276
+ // OAuth landing is the one place here where the query IS the payload.
277
+ const reqPath = (req.url ?? "/").split("?")[0];
278
+ if (req.method === "GET" && reqPath === cfg.oauthPath) {
279
+ const url = new URL(req.url ?? "/", "http://localhost");
280
+ const { status, html } = await handleOAuthCallback(url, {
281
+ clientId: cfg.clientId,
282
+ clientSecret: cfg.secret,
283
+ });
284
+ res.writeHead(status, { "content-type": "text/html; charset=utf-8" }).end(html);
285
+ return;
286
+ }
159
287
  if (req.method !== "POST") {
160
288
  res.writeHead(405).end();
161
289
  return;
162
290
  }
163
- // Ignore query strings; a proxy may append them.
164
- const reqPath = (req.url ?? "/").split("?")[0];
165
291
  if (reqPath !== cfg.path) {
166
292
  res.writeHead(404).end();
167
293
  return;
@@ -202,22 +328,119 @@ export function createWebhookServer(cfg, deps) {
202
328
  return;
203
329
  }
204
330
  seen.add(key);
205
- const decision = route(event, cfg);
331
+ // Deleting the project revokes its ingress. A grant outliving the thing
332
+ // it points at is dead weight in a security boundary: it reads as though
333
+ // access is still open, and if the id is ever reused it silently is. The
334
+ // boundary should only ever shrink on its own.
335
+ if (event.event_name === "project:deleted") {
336
+ const gone = String(event.event_data?.id ?? "");
337
+ if (gone) {
338
+ const { revokeIngress } = await import("./todoist-ingress.js");
339
+ if (revokeIngress(gone)) {
340
+ log(`todoist-webhook: project ${gone} was deleted — its ingress grant is revoked`);
341
+ }
342
+ }
343
+ // Still not actionable as work; fall through to be recorded as ignored.
344
+ }
345
+ // A comment carries item_id and its own text and nothing else — no
346
+ // project, no labels — so the boundary cannot be evaluated until the
347
+ // parent task is resolved. Do it before routing, and let a failed lookup
348
+ // drop the event rather than route it against a missing project, which
349
+ // the allowlist would refuse anyway but for the wrong reason.
350
+ if (event.event_name === "note:added" && !event.event_data?.project_id) {
351
+ const parentId = String(event.event_data?.item_id ?? "");
352
+ if (!parentId) {
353
+ audit({
354
+ action: "webhook", actor: "todoist", target: "aibroker", outcome: "ignored",
355
+ reason: "comment carries no item_id", meta: { event: event.event_name },
356
+ });
357
+ return;
358
+ }
359
+ try {
360
+ const { fetchParentTask } = await import("./todoist-reply.js");
361
+ const parent = await fetchParentTask(parentId);
362
+ event.event_data = {
363
+ ...event.event_data,
364
+ id: parentId,
365
+ project_id: parent.projectId,
366
+ labels: parent.labels,
367
+ // Answer on the task, about the task: the comment is the
368
+ // instruction, the title is the context it makes sense in.
369
+ description: parent.content ? `(comment on "${parent.content}")` : "",
370
+ };
371
+ }
372
+ catch (err) {
373
+ const reason = `could not resolve the task a comment belongs to — ${err instanceof Error ? err.message : String(err)}`;
374
+ audit({
375
+ action: "webhook", actor: "todoist", target: "aibroker",
376
+ outcome: "ignored", reason, meta: { event: event.event_name, taskId: parentId },
377
+ });
378
+ log(`todoist-webhook: ${reason}`);
379
+ return;
380
+ }
381
+ }
382
+ const isComment = event.event_name === "note:added";
383
+ const parentId = String(event.event_data?.id ?? "");
384
+ const { ownerOf, rememberOwner } = await import("./todoist-owners.js");
385
+ // Grants made since the daemon started take effect now, not at the next
386
+ // restart. A project created and granted while you are using the system
387
+ // has to work immediately, or the grant is indistinguishable from a
388
+ // project that routes nowhere.
389
+ const { applyGrants } = await import("./todoist-ingress.js");
390
+ const live = applyGrants(cfg);
391
+ const decision = route(event, live, await (deps.knownOwners?.() ?? []), isComment ? ownerOf(parentId) : undefined);
206
392
  const who = event.initiator?.email ?? event.initiator?.full_name ?? "todoist";
393
+ if (decision.nearMiss) {
394
+ // Recorded whether or not the task was delivered: the point is that
395
+ // something was asked for and not honoured.
396
+ log(`todoist-webhook: near miss — ${decision.nearMiss}`);
397
+ }
207
398
  if (!decision.act) {
208
399
  audit({
209
400
  action: "webhook", actor: `todoist:${who}`, target: "aibroker",
210
401
  outcome: "ignored", reason: decision.reason,
211
- meta: { event: event.event_name, task: event.event_data?.content ?? undefined },
402
+ meta: {
403
+ event: event.event_name,
404
+ task: event.event_data?.content ?? undefined,
405
+ nearMiss: decision.nearMiss,
406
+ },
212
407
  });
213
408
  return;
214
409
  }
215
410
  try {
216
- const r = await deps.deliver(decision.project, decision.body);
411
+ // The id rides along so the session can answer on the task it came
412
+ // from. Without it the reply has nowhere to go but a terminal the
413
+ // asker is not looking at.
414
+ // Warn when the title is not unique in its project. Two tasks with the
415
+ // same name are indistinguishable in a list, so an answer posted on one
416
+ // looks — to whoever is watching the other — exactly like being ignored.
417
+ // The session is told, so it can say which id it answered on.
418
+ let twins = 0;
419
+ if (!isComment) {
420
+ try {
421
+ const { countTasksWithTitle } = await import("./todoist-reply.js");
422
+ twins = await countTasksWithTitle(String(event.event_data?.project_id ?? ""), String(event.event_data?.content ?? ""));
423
+ }
424
+ catch { /* a lookup failure must not block a delivery */ }
425
+ }
426
+ const twinWarning = twins > 1
427
+ ? `\n\n[note: ${twins} open tasks in this project share this title — say which id you answered on]`
428
+ : "";
429
+ const delivered = `${decision.body}${twinWarning}\n\n[todoist:${decision.taskId}]`;
430
+ const r = await deps.deliver(decision.project, delivered, isComment ? { prefix: "[Task:comment]" } : undefined);
431
+ // Remember who took it, so a later comment reaches the same session.
432
+ // Recorded on the way out and only on a real delivery: a task nobody
433
+ // accepted has no owner to inherit.
434
+ if (!isComment && r.outcome === "delivered")
435
+ rememberOwner(decision.taskId, decision.project);
217
436
  audit({
218
437
  action: "webhook", actor: `todoist:${who}`, target: r.session || decision.project,
219
438
  outcome: r.outcome, body: decision.body, reason: r.reason,
220
- meta: { event: event.event_name, taskId: decision.taskId },
439
+ meta: {
440
+ event: event.event_name, taskId: decision.taskId,
441
+ rule: decision.rule, nearMiss: decision.nearMiss,
442
+ duplicateTitles: twins > 1 ? twins : undefined,
443
+ },
221
444
  });
222
445
  }
223
446
  catch (err) {
@@ -254,6 +477,8 @@ export function webhookConfigFromEnv() {
254
477
  port: Number(process.env.TODOIST_WEBHOOK_PORT) || 8766,
255
478
  bind: process.env.TODOIST_WEBHOOK_BIND ?? "127.0.0.1",
256
479
  path: process.env.TODOIST_WEBHOOK_PATH ?? "/todoist",
480
+ clientId: process.env.TODOIST_CLIENT_ID,
481
+ oauthPath: process.env.TODOIST_OAUTH_PATH ?? "/oauth",
257
482
  ingressProjectIds: ids,
258
483
  projectOwners: owners,
259
484
  defaultOwner: process.env.TODOIST_DEFAULT_OWNER,
@@ -280,6 +505,8 @@ export function startTodoistWebhook(deps) {
280
505
  server.listen(cfg.port, cfg.bind, () => {
281
506
  log(`todoist-webhook: listening on ${cfg.bind}:${cfg.port}${cfg.path}, ` +
282
507
  `${cfg.ingressProjectIds.size} ingress project(s), default owner ${cfg.defaultOwner ?? "(none)"}`);
508
+ log(`todoist-webhook: OAuth landing on ${cfg.oauthPath}` +
509
+ (cfg.clientId ? "" : " — inert until TODOIST_CLIENT_ID is set"));
283
510
  });
284
511
  server.on("error", (err) => log(`todoist-webhook: server error — ${err.message}`));
285
512
  return server;