auggy 0.3.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/CHANGELOG.md +96 -0
- package/LICENSE +201 -0
- package/README.md +161 -0
- package/package.json +76 -0
- package/src/agent-card.ts +39 -0
- package/src/agent.ts +283 -0
- package/src/agentmail-client.ts +138 -0
- package/src/augments/bash/index.ts +463 -0
- package/src/augments/bash/skill/SKILL.md +156 -0
- package/src/augments/budgets/budget-store.ts +513 -0
- package/src/augments/budgets/index.ts +134 -0
- package/src/augments/budgets/preamble.ts +93 -0
- package/src/augments/budgets/types.ts +89 -0
- package/src/augments/file-memory/index.ts +71 -0
- package/src/augments/filesystem/index.ts +533 -0
- package/src/augments/filesystem/skill/SKILL.md +142 -0
- package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
- package/src/augments/layered-memory/extractor/buffer.ts +56 -0
- package/src/augments/layered-memory/extractor/frequency.ts +79 -0
- package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
- package/src/augments/layered-memory/extractor/parse.ts +75 -0
- package/src/augments/layered-memory/extractor/prompt.md +26 -0
- package/src/augments/layered-memory/index.ts +757 -0
- package/src/augments/layered-memory/skill/SKILL.md +153 -0
- package/src/augments/layered-memory/storage/migrations/README.md +16 -0
- package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
- package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
- package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
- package/src/augments/layered-memory/storage/types.ts +98 -0
- package/src/augments/link/index.ts +489 -0
- package/src/augments/link/translate.ts +261 -0
- package/src/augments/notify/adapters/agentmail.ts +70 -0
- package/src/augments/notify/adapters/telegram.ts +60 -0
- package/src/augments/notify/adapters/webhook.ts +55 -0
- package/src/augments/notify/index.ts +284 -0
- package/src/augments/notify/skill/SKILL.md +150 -0
- package/src/augments/org-context/index.ts +721 -0
- package/src/augments/org-context/skill/SKILL.md +96 -0
- package/src/augments/skills/index.ts +103 -0
- package/src/augments/supabase-memory/index.ts +151 -0
- package/src/augments/telegram-transport/index.ts +312 -0
- package/src/augments/telegram-transport/polling.ts +55 -0
- package/src/augments/telegram-transport/webhook.ts +56 -0
- package/src/augments/turn-control/index.ts +61 -0
- package/src/augments/turn-control/skill/SKILL.md +155 -0
- package/src/augments/visitor-auth/email-validation.ts +66 -0
- package/src/augments/visitor-auth/index.ts +779 -0
- package/src/augments/visitor-auth/rate-limiter.ts +90 -0
- package/src/augments/visitor-auth/skill/SKILL.md +55 -0
- package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
- package/src/augments/visitor-auth/storage/types.ts +164 -0
- package/src/augments/visitor-auth/types.ts +123 -0
- package/src/augments/visitor-auth/verify-page.ts +179 -0
- package/src/augments/web-fetch/index.ts +331 -0
- package/src/augments/web-fetch/skill/SKILL.md +100 -0
- package/src/cli/agent-index.ts +289 -0
- package/src/cli/augment-catalog.ts +320 -0
- package/src/cli/augment-resolver.ts +597 -0
- package/src/cli/commands/add-skill.ts +194 -0
- package/src/cli/commands/add.ts +87 -0
- package/src/cli/commands/chat.ts +207 -0
- package/src/cli/commands/create.ts +462 -0
- package/src/cli/commands/dev.ts +139 -0
- package/src/cli/commands/eval.ts +180 -0
- package/src/cli/commands/ls.ts +66 -0
- package/src/cli/commands/remove.ts +95 -0
- package/src/cli/commands/restart.ts +40 -0
- package/src/cli/commands/start.ts +123 -0
- package/src/cli/commands/status.ts +104 -0
- package/src/cli/commands/stop.ts +84 -0
- package/src/cli/commands/visitors-revoke.ts +155 -0
- package/src/cli/commands/visitors.ts +101 -0
- package/src/cli/config-parser.ts +1034 -0
- package/src/cli/engine-resolver.ts +68 -0
- package/src/cli/index.ts +178 -0
- package/src/cli/model-picker.ts +89 -0
- package/src/cli/pid-registry.ts +146 -0
- package/src/cli/plist-generator.ts +117 -0
- package/src/cli/resolve-config.ts +56 -0
- package/src/cli/scaffold-skills.ts +158 -0
- package/src/cli/scaffold.ts +291 -0
- package/src/cli/skill-frontmatter.ts +51 -0
- package/src/cli/skill-validator.ts +151 -0
- package/src/cli/types.ts +228 -0
- package/src/cli/yaml-helpers.ts +66 -0
- package/src/engines/_shared/cost.ts +55 -0
- package/src/engines/_shared/schema-normalize.ts +75 -0
- package/src/engines/anthropic/pricing.ts +117 -0
- package/src/engines/anthropic.ts +483 -0
- package/src/engines/openai/pricing.ts +67 -0
- package/src/engines/openai.ts +446 -0
- package/src/engines/openrouter/pricing.ts +83 -0
- package/src/engines/openrouter.ts +185 -0
- package/src/helpers.ts +24 -0
- package/src/http.ts +387 -0
- package/src/index.ts +165 -0
- package/src/kernel/capability-table.ts +172 -0
- package/src/kernel/context-allocator.ts +161 -0
- package/src/kernel/history-manager.ts +198 -0
- package/src/kernel/lifecycle-manager.ts +106 -0
- package/src/kernel/output-validator.ts +35 -0
- package/src/kernel/preamble.ts +23 -0
- package/src/kernel/route-collector.ts +97 -0
- package/src/kernel/timeout.ts +21 -0
- package/src/kernel/tool-selector.ts +47 -0
- package/src/kernel/trace-emitter.ts +66 -0
- package/src/kernel/transport-queue.ts +147 -0
- package/src/kernel/turn-loop.ts +1148 -0
- package/src/memory/context-synthesis.ts +83 -0
- package/src/memory/memory-bus.ts +61 -0
- package/src/memory/registry.ts +80 -0
- package/src/memory/tools.ts +320 -0
- package/src/memory/types.ts +8 -0
- package/src/parts.ts +30 -0
- package/src/scaffold-templates/identity.md +31 -0
- package/src/telegram-client.ts +145 -0
- package/src/tokenizer.ts +14 -0
- package/src/transports/ag-ui-events.ts +253 -0
- package/src/transports/visitor-token.ts +82 -0
- package/src/transports/web-transport.ts +948 -0
- package/src/types.ts +1009 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { TelegramBotClient, TelegramUpdate } from "../../telegram-client";
|
|
2
|
+
|
|
3
|
+
export interface PollLoopOptions {
|
|
4
|
+
client: TelegramBotClient;
|
|
5
|
+
timeoutSec?: number;
|
|
6
|
+
onUpdate: (update: TelegramUpdate) => void | Promise<void>;
|
|
7
|
+
errorBackoffMs?: number;
|
|
8
|
+
log?: { warn: (msg: string) => void };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PollLoopHandle {
|
|
12
|
+
stop(): void;
|
|
13
|
+
done: Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function runPollLoop(opts: PollLoopOptions): PollLoopHandle {
|
|
17
|
+
const timeoutSec = opts.timeoutSec ?? 30;
|
|
18
|
+
const errorBackoffMs = opts.errorBackoffMs ?? 5000;
|
|
19
|
+
const log = opts.log ?? console;
|
|
20
|
+
|
|
21
|
+
let stopped = false;
|
|
22
|
+
let nextOffset: number | undefined;
|
|
23
|
+
|
|
24
|
+
const done = (async () => {
|
|
25
|
+
while (true) {
|
|
26
|
+
let updates: TelegramUpdate[];
|
|
27
|
+
try {
|
|
28
|
+
updates = await opts.client.getUpdates({ offset: nextOffset, timeoutSec });
|
|
29
|
+
} catch (err) {
|
|
30
|
+
log.warn(
|
|
31
|
+
`[telegram-transport.polling] getUpdates error: ${(err as Error).message} — retrying in ${errorBackoffMs}ms`,
|
|
32
|
+
);
|
|
33
|
+
await new Promise((r) => setTimeout(r, errorBackoffMs));
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (stopped) break;
|
|
37
|
+
for (const update of updates) {
|
|
38
|
+
if (stopped) break;
|
|
39
|
+
nextOffset = update.update_id + 1;
|
|
40
|
+
await opts.onUpdate(update);
|
|
41
|
+
}
|
|
42
|
+
// Yield to the macrotask queue between iterations so that callers (e.g.
|
|
43
|
+
// tests using setTimeout to signal stop) can fire between polls. In
|
|
44
|
+
// production this is a no-op relative to the 30 s long-poll latency.
|
|
45
|
+
if (!stopped) await new Promise<void>((r) => setTimeout(r, 0));
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
stop() {
|
|
51
|
+
stopped = true;
|
|
52
|
+
},
|
|
53
|
+
done,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { TelegramUpdate } from "../../telegram-client";
|
|
3
|
+
|
|
4
|
+
export interface WebhookServerOptions {
|
|
5
|
+
port: number;
|
|
6
|
+
secretToken: string;
|
|
7
|
+
onUpdate: (update: TelegramUpdate) => void | Promise<void>;
|
|
8
|
+
log?: { warn: (msg: string) => void };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WebhookServerHandle {
|
|
12
|
+
stop(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function startWebhookServer(opts: WebhookServerOptions): Promise<WebhookServerHandle> {
|
|
16
|
+
const log = opts.log ?? console;
|
|
17
|
+
const expected = Buffer.from(opts.secretToken, "utf8");
|
|
18
|
+
|
|
19
|
+
function safeCompare(provided: string | null): boolean {
|
|
20
|
+
if (provided == null) return false;
|
|
21
|
+
const providedBuf = Buffer.from(provided, "utf8");
|
|
22
|
+
if (providedBuf.length !== expected.length) return false;
|
|
23
|
+
return timingSafeEqual(providedBuf, expected);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const server = Bun.serve({
|
|
27
|
+
port: opts.port,
|
|
28
|
+
async fetch(req: Request): Promise<Response> {
|
|
29
|
+
if (req.method !== "POST") {
|
|
30
|
+
return new Response(null, { status: 405 });
|
|
31
|
+
}
|
|
32
|
+
const provided = req.headers.get("x-telegram-bot-api-secret-token");
|
|
33
|
+
if (!safeCompare(provided)) {
|
|
34
|
+
return new Response(null, { status: 401 });
|
|
35
|
+
}
|
|
36
|
+
let body: unknown;
|
|
37
|
+
try {
|
|
38
|
+
body = await req.json();
|
|
39
|
+
} catch {
|
|
40
|
+
return new Response(null, { status: 400 });
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
await opts.onUpdate(body as TelegramUpdate);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
log.warn(`[telegram-transport.webhook] onUpdate threw: ${(err as Error).message}`);
|
|
46
|
+
}
|
|
47
|
+
return new Response(null, { status: 200 });
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
stop() {
|
|
53
|
+
server.stop(true);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Augment, ToolResult } from "../../types";
|
|
3
|
+
import { defineAugment, defineTool } from "../../helpers";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* turnControl augment — turn-lifecycle directive tools.
|
|
7
|
+
*
|
|
8
|
+
* v1 ships one tool: `request_input(prompt)`. The model calls it when it
|
|
9
|
+
* needs an answer to proceed; the kernel ends the turn with status
|
|
10
|
+
* `input-required` and the prompt becomes the user-visible reply.
|
|
11
|
+
*
|
|
12
|
+
* Future tools (deferred): `defer(eta)`, `escalate(level)`. The augment
|
|
13
|
+
* seam is reserved so adding them does not require kernel changes.
|
|
14
|
+
*/
|
|
15
|
+
export interface TurnControlOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Override the request_input tool description if the operator wants to
|
|
18
|
+
* constrain or expand when the model should call it. Default text tells
|
|
19
|
+
* the model to ask only when blocked on missing user input, not as a
|
|
20
|
+
* closing pleasantry.
|
|
21
|
+
*/
|
|
22
|
+
requestInputDescription?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_REQUEST_INPUT_DESCRIPTION =
|
|
26
|
+
"Pause this turn and ask the user for more information. " +
|
|
27
|
+
"Use this when you need an answer to proceed and cannot reasonably guess. " +
|
|
28
|
+
"The `prompt` argument is shown to the user as your reply; the conversation " +
|
|
29
|
+
"resumes on their next message. The turn ends with status 'input-required'. " +
|
|
30
|
+
"Do not use this as a closing pleasantry — only when you are actually blocked on missing input.";
|
|
31
|
+
|
|
32
|
+
export function turnControl(opts: TurnControlOptions = {}): Augment {
|
|
33
|
+
const requestInput = defineTool({
|
|
34
|
+
name: "request_input",
|
|
35
|
+
description: opts.requestInputDescription ?? DEFAULT_REQUEST_INPUT_DESCRIPTION,
|
|
36
|
+
category: "meta",
|
|
37
|
+
input: z.object({
|
|
38
|
+
prompt: z
|
|
39
|
+
.string()
|
|
40
|
+
.min(1)
|
|
41
|
+
.max(2000)
|
|
42
|
+
.describe(
|
|
43
|
+
"The question or prompt shown to the user. Becomes the assistant's visible reply. Max 2000 chars.",
|
|
44
|
+
),
|
|
45
|
+
reason: z
|
|
46
|
+
.string()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("Optional internal note for tracing. Not shown to the user."),
|
|
49
|
+
}),
|
|
50
|
+
execute: async ({ prompt }): Promise<ToolResult> => ({
|
|
51
|
+
content: prompt,
|
|
52
|
+
terminate: { status: "input-required", message: prompt },
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return defineAugment({
|
|
57
|
+
name: "turnControl",
|
|
58
|
+
capabilities: ["tools"],
|
|
59
|
+
tools: [requestInput],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: turn-control
|
|
3
|
+
description: Pause the current turn and ask the user a specific, answerable question when you genuinely need their input to proceed. Use when blocked on missing information that you cannot reasonably guess; do NOT use as a closing pleasantry.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Turn Control
|
|
7
|
+
|
|
8
|
+
You can pause the current turn and surface a question to the user as your reply. The conversation resumes when they answer. Use this when you are actually blocked on missing information — not as a polite sign-off, and not to dodge a hard call.
|
|
9
|
+
|
|
10
|
+
## Tool
|
|
11
|
+
|
|
12
|
+
| Tool | What it does | When to use |
|
|
13
|
+
|------|-------------|-------------|
|
|
14
|
+
| `request_input(prompt, reason?)` | End this turn with status `input-required`; `prompt` becomes your visible reply to the user | When you genuinely need an answer from the user to do the work and cannot reasonably proceed without it |
|
|
15
|
+
|
|
16
|
+
Inputs:
|
|
17
|
+
- `prompt` — the question shown to the user. This text becomes your assistant reply for this turn. Maximum 2000 characters; one or two sentences is almost always right.
|
|
18
|
+
- `reason` *(optional)* — a short internal note for tracing. Not shown to the user.
|
|
19
|
+
|
|
20
|
+
When you call `request_input`, the turn ends immediately. There is no follow-up tool call after it. Whatever you put in `prompt` IS your reply for this turn.
|
|
21
|
+
|
|
22
|
+
## When to call it
|
|
23
|
+
|
|
24
|
+
Use `request_input` when there's real, well-defined ambiguity AND the right next step depends on the answer.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
GOOD:
|
|
28
|
+
User: "Send the report to my email."
|
|
29
|
+
→ You don't have an email on file and the user has more than one address
|
|
30
|
+
they've used in the past.
|
|
31
|
+
→ request_input("Which address should I send it to — the gmail or the
|
|
32
|
+
work one?")
|
|
33
|
+
|
|
34
|
+
GOOD:
|
|
35
|
+
User: "Cancel the booking."
|
|
36
|
+
→ They have two active bookings.
|
|
37
|
+
→ request_input("You have two active bookings — the Tuesday one or the
|
|
38
|
+
Friday one?")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Do not use `request_input` when you can reasonably proceed without asking, when the question is rhetorical, or when you're using it to avoid making a defensible call.
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
BAD (avoiding a defensible call):
|
|
45
|
+
User: "Pick one and book it."
|
|
46
|
+
→ They explicitly delegated.
|
|
47
|
+
→ request_input("Which one would you like me to pick?")
|
|
48
|
+
← they already answered this question
|
|
49
|
+
|
|
50
|
+
BAD (closing pleasantry):
|
|
51
|
+
→ "I've finished the task. Is there anything else?"
|
|
52
|
+
wrapped in request_input(...)
|
|
53
|
+
← that's a normal closing line; it does not need to halt the turn
|
|
54
|
+
|
|
55
|
+
BAD (unanswerable / vague):
|
|
56
|
+
→ request_input("How would you like me to approach this?")
|
|
57
|
+
← too open; if you're stuck, narrow it to a concrete choice
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Phrase the question well
|
|
61
|
+
|
|
62
|
+
A good `request_input` prompt has three properties:
|
|
63
|
+
|
|
64
|
+
1. **Specific.** The user can see exactly what you're asking and why it matters.
|
|
65
|
+
2. **Answerable.** The user can give an answer in one short message — ideally a pick-from-N or a short value.
|
|
66
|
+
3. **Self-contained.** Reading the prompt alone (without the prior message) tells the user what's going on.
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
GOOD: "Which address should I use — sam@home.com or sam@work.com?"
|
|
70
|
+
GOOD: "What date should the booking be? Any day next week works on the venue's side."
|
|
71
|
+
GOOD: "Should the cleanup also remove the .log files, or only the .tmp files?"
|
|
72
|
+
|
|
73
|
+
LESS GOOD: "Can you clarify?" ← clarify what
|
|
74
|
+
LESS GOOD: "What do you want me to do?" ← they already told you
|
|
75
|
+
LESS GOOD: "Is this what you meant?" ← yes/no without context
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Lead with the choice or the value you need. Skip preamble.
|
|
79
|
+
|
|
80
|
+
## Don't loop
|
|
81
|
+
|
|
82
|
+
If the user answers and you still feel uncertain, do NOT immediately call `request_input` again with a near-identical question. Either:
|
|
83
|
+
- Make the best call given what you have, and tell them what you decided so they can correct you, or
|
|
84
|
+
- Ask a meaningfully different question that breaks the ambiguity.
|
|
85
|
+
|
|
86
|
+
Calling `request_input` a second time with a refined question is fine when their answer revealed a new dimension. Calling it three or four times in a row is bad — it feels like an interrogation and burns the user's patience.
|
|
87
|
+
|
|
88
|
+
## Don't surrender on the first ambiguity
|
|
89
|
+
|
|
90
|
+
A small, defensible inference is almost always better than a halt. If the user said "send it to me" and you have one email on file, just use it (and tell them which one you used so they can correct you). If you have two and they're equally plausible, that's where `request_input` earns its keep.
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
BAD:
|
|
94
|
+
User: "What's the weather today?"
|
|
95
|
+
→ request_input("Which city should I check for?")
|
|
96
|
+
← if you know they're in Berlin from earlier in the chat, just check Berlin
|
|
97
|
+
and offer to switch cities if you guessed wrong
|
|
98
|
+
|
|
99
|
+
GOOD:
|
|
100
|
+
User: "What's the weather today?" (no location anywhere in context)
|
|
101
|
+
→ request_input("Where are you — what city should I check?")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Heuristic: if a thoughtful human assistant in the same situation would just make the call and mention the assumption out loud, do that. If they would actually have to stop and ask, call `request_input`.
|
|
105
|
+
|
|
106
|
+
## Common mistakes
|
|
107
|
+
|
|
108
|
+
| Mistake | Why it bites |
|
|
109
|
+
|---------|--------------|
|
|
110
|
+
| Using `request_input` as the closing line of a normal turn | Ends the turn with `input-required`, signaling to the user that you need something from them; for a normal sign-off, just reply with text |
|
|
111
|
+
| Asking a question the user already answered | Re-reads as not paying attention; defaults to making the obvious call instead |
|
|
112
|
+
| Vague open prompts ("what would you like me to do?") | Forces the user to do your thinking; offer a concrete choice |
|
|
113
|
+
| Looping on the same ambiguity after they responded | Make a best-effort call and offer to revise |
|
|
114
|
+
| Asking before doing any of the inference you could have done | Try one defensible interpretation first; only halt when there's a real branch |
|
|
115
|
+
| Embedding multiple questions in one prompt ("which file? and what format? and where?") | One question per halt; if you need three answers, ask the most decisive one first |
|
|
116
|
+
| Putting long preamble before the actual question | The user reads the prompt as your reply; lead with the question |
|
|
117
|
+
|
|
118
|
+
## Examples
|
|
119
|
+
|
|
120
|
+
### Real ambiguity — call it
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
User: "Refund my last order."
|
|
124
|
+
|
|
125
|
+
You see two recent orders, equal recency, both refundable.
|
|
126
|
+
|
|
127
|
+
request_input("I see two recent orders — order #4421 (the headphones)
|
|
128
|
+
or order #4438 (the cable)? Which would you like refunded?")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Defensible inference — don't halt
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
User: "Email me the invoice."
|
|
135
|
+
|
|
136
|
+
You have one email on file: sam@example.com.
|
|
137
|
+
|
|
138
|
+
→ email it to sam@example.com; tell them "Sent to sam@example.com — let
|
|
139
|
+
me know if you'd rather use a different address."
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Actually closing — don't halt
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
User: "Thanks, that's all."
|
|
146
|
+
|
|
147
|
+
→ reply: "Glad I could help. Reach out anytime."
|
|
148
|
+
(No request_input — there's nothing you need from them.)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## What this tool is not
|
|
152
|
+
|
|
153
|
+
- It is not a confirmation step. If you want to confirm a destructive action, ask in plain text and wait for the user's reply naturally — `request_input` halts the turn, but your normal reply also halts the turn. Halting is not the value; the structured `input-required` status is, and that only matters when something downstream is reading it.
|
|
154
|
+
- It is not a way to delegate decisions to the user that they have already delegated to you.
|
|
155
|
+
- It is not a substitute for using your other tools to gather information you could find yourself.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email-format validation + "did the visitor actually say this address?"
|
|
3
|
+
* substring search. Pure functions; no IO, no SQL.
|
|
4
|
+
*
|
|
5
|
+
* Defense layer for spec fix #4 (confused-deputy): visitorAuth refuses to
|
|
6
|
+
* mint a token for an email the visitor never typed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { RecentVisitorMessage } from "./types";
|
|
10
|
+
|
|
11
|
+
// Conservative email pattern: local @ domain with a TLD ≥ 2 chars.
|
|
12
|
+
// Deliberately stricter than RFC 5322 — we'd rather false-reject some
|
|
13
|
+
// exotic-but-valid addresses than risk header injection or smuggled
|
|
14
|
+
// whitespace. Operators with weird-domain visitors can layer their own
|
|
15
|
+
// validation pre-call.
|
|
16
|
+
const EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$/;
|
|
17
|
+
const MAX_EMAIL_LEN = 254; // RFC 5321 §4.5.3.1
|
|
18
|
+
|
|
19
|
+
export function isWellFormedEmail(value: string): boolean {
|
|
20
|
+
if (typeof value !== "string") return false;
|
|
21
|
+
if (value.length === 0 || value.length > MAX_EMAIL_LEN) return false;
|
|
22
|
+
// Reject control characters explicitly — header injection guard.
|
|
23
|
+
if (/[\r\n\t]/.test(value)) return false;
|
|
24
|
+
// Reject double-dot in domain.
|
|
25
|
+
if (/\.\./.test(value)) return false;
|
|
26
|
+
return EMAIL_PATTERN.test(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type RecentMessageMatch =
|
|
30
|
+
| { matched: true; messageId: string }
|
|
31
|
+
| { matched: false; hint?: "malformed" | "near-match" };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Case-insensitive, word-boundary-aware search for `email` across the
|
|
35
|
+
* visitor's recent messages. Returns the messageId of the first hit, or
|
|
36
|
+
* a structured non-match with a debug-only `hint`.
|
|
37
|
+
*
|
|
38
|
+
* Word-boundary matching is necessary so `ice@example.com` does NOT match
|
|
39
|
+
* a transcript containing `alice@example.com`.
|
|
40
|
+
*/
|
|
41
|
+
export function emailAppearsInRecentMessages(
|
|
42
|
+
email: string,
|
|
43
|
+
messages: readonly RecentVisitorMessage[],
|
|
44
|
+
): RecentMessageMatch {
|
|
45
|
+
if (!isWellFormedEmail(email)) return { matched: false, hint: "malformed" };
|
|
46
|
+
const target = email.toLowerCase();
|
|
47
|
+
// Email regex with negative lookbehind/lookahead approximation —
|
|
48
|
+
// require the char before/after to NOT be part of an email-local-name char.
|
|
49
|
+
const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
50
|
+
const boundary = new RegExp(`(^|[^A-Za-z0-9._%+-])${escaped}(?![A-Za-z0-9._%+-])`, "i");
|
|
51
|
+
|
|
52
|
+
for (const msg of messages) {
|
|
53
|
+
if (!msg.text) continue;
|
|
54
|
+
if (boundary.test(msg.text)) {
|
|
55
|
+
return { matched: true, messageId: msg.messageId ?? "" };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Did the email's local-part appear in some message but with different
|
|
59
|
+
// surrounding context? Useful debug hint, never surfaced to the model.
|
|
60
|
+
for (const msg of messages) {
|
|
61
|
+
if (msg.text?.toLowerCase().includes(target)) {
|
|
62
|
+
return { matched: false, hint: "near-match" };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { matched: false };
|
|
66
|
+
}
|