@termaxjs/web-ai 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ export * from "./types.js";
2
+ export * from "./config.js";
3
+ export * from "./lib/index.js";
4
+ export * from "./tools/tools.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./types.js";
2
+ export * from "./config.js";
3
+ export * from "./lib/index.js";
4
+ export * from "./tools/tools.js";
@@ -0,0 +1,8 @@
1
+ import type { ModelMessage } from "ai";
2
+ export type CompactResult = {
3
+ messages: ModelMessage[];
4
+ compacted: boolean;
5
+ droppedCount: number;
6
+ };
7
+ export declare function compactModelMessages(messages: ModelMessage[], contextLimit: number): ModelMessage[];
8
+ export declare function compactModelMessagesDetailed(messages: ModelMessage[], contextLimit: number): CompactResult;
@@ -0,0 +1,179 @@
1
+ const KEEP_TAIL = 24;
2
+ const ELISION_TEXT = "[elided to save context — see prior tool call in history]";
3
+ function approxBytes(messages) {
4
+ let n = 0;
5
+ for (const m of messages) {
6
+ if (typeof m.content === "string")
7
+ n += m.content.length;
8
+ else if (Array.isArray(m.content)) {
9
+ for (const part of m.content) {
10
+ if (part.type === "text" && typeof part.text === "string")
11
+ n += part.text.length;
12
+ else if (part.type === "tool-result")
13
+ n += JSON.stringify(part.output ?? "").length;
14
+ else if (part.type === "tool-call")
15
+ n += JSON.stringify(part.input ?? "").length;
16
+ else
17
+ n += 64;
18
+ }
19
+ }
20
+ }
21
+ return n;
22
+ }
23
+ function elideToolResult(part) {
24
+ if (part.type !== "tool-result")
25
+ return { changed: false, part };
26
+ if (part.output &&
27
+ typeof part.output === "object" &&
28
+ part.output.__elided) {
29
+ return { changed: false, part };
30
+ }
31
+ return {
32
+ changed: true,
33
+ part: {
34
+ ...part,
35
+ output: { type: "text", value: ELISION_TEXT, __elided: true },
36
+ },
37
+ };
38
+ }
39
+ function pathOfInput(input) {
40
+ if (!input || typeof input !== "object")
41
+ return null;
42
+ const p = input.path;
43
+ return typeof p === "string" && p.length > 0 ? p : null;
44
+ }
45
+ function collectMutationPaths(messages) {
46
+ const paths = new Set();
47
+ for (const m of messages) {
48
+ if (!Array.isArray(m.content))
49
+ continue;
50
+ for (const part of m.content) {
51
+ if (part.type !== "tool-call")
52
+ continue;
53
+ const name = part.toolName;
54
+ if (name === "edit" ||
55
+ name === "multi_edit" ||
56
+ name === "write_file" ||
57
+ name === "create_directory") {
58
+ const p = pathOfInput(part.input);
59
+ if (p)
60
+ paths.add(p);
61
+ }
62
+ }
63
+ }
64
+ return paths;
65
+ }
66
+ function collectLastReadIdxPerPath(messages) {
67
+ const lastIdx = new Map();
68
+ for (let i = 0; i < messages.length; i++) {
69
+ const m = messages[i];
70
+ if (!Array.isArray(m.content))
71
+ continue;
72
+ for (const part of m.content) {
73
+ if (part.type !== "tool-call")
74
+ continue;
75
+ if (part.toolName !== "read_file")
76
+ continue;
77
+ const p = pathOfInput(part.input);
78
+ if (p)
79
+ lastIdx.set(p, i);
80
+ }
81
+ }
82
+ return lastIdx;
83
+ }
84
+ function dropSupersededReads(messages) {
85
+ const mutated = collectMutationPaths(messages);
86
+ const lastReadIdx = collectLastReadIdxPerPath(messages);
87
+ const callIdxToPath = new Map();
88
+ for (let i = 0; i < messages.length; i++) {
89
+ const m = messages[i];
90
+ if (!Array.isArray(m.content))
91
+ continue;
92
+ for (const part of m.content) {
93
+ if (part.type !== "tool-call" || part.toolName !== "read_file")
94
+ continue;
95
+ const p = pathOfInput(part.input);
96
+ const id = part.toolCallId;
97
+ if (p && typeof id === "string")
98
+ callIdxToPath.set(id, p);
99
+ }
100
+ }
101
+ let touched = false;
102
+ const out = messages.map((m, i) => {
103
+ if (!Array.isArray(m.content))
104
+ return m;
105
+ let local = false;
106
+ const nextContent = m.content.map((part) => {
107
+ if (part.type !== "tool-result")
108
+ return part;
109
+ const id = part.toolCallId;
110
+ if (typeof id !== "string")
111
+ return part;
112
+ const path = callIdxToPath.get(id);
113
+ if (!path)
114
+ return part;
115
+ const isStale = mutated.has(path) ||
116
+ (lastReadIdx.has(path) && lastReadIdx.get(path) > i);
117
+ if (!isStale)
118
+ return part;
119
+ const r = elideToolResult(part);
120
+ if (r.changed)
121
+ local = true;
122
+ return r.part;
123
+ });
124
+ if (!local)
125
+ return m;
126
+ touched = true;
127
+ return { ...m, content: nextContent };
128
+ });
129
+ return { out, touched };
130
+ }
131
+ export function compactModelMessages(messages, contextLimit) {
132
+ return compactModelMessagesDetailed(messages, contextLimit).messages;
133
+ }
134
+ export function compactModelMessagesDetailed(messages, contextLimit) {
135
+ let dropped = 0;
136
+ let working = messages;
137
+ let approxTokens = approxBytes(working) / 4;
138
+ if (approxTokens >= 0.55 * contextLimit) {
139
+ const r = dropSupersededReads(working);
140
+ if (r.touched) {
141
+ working = r.out;
142
+ dropped++;
143
+ approxTokens = approxBytes(working) / 4;
144
+ }
145
+ }
146
+ if (approxTokens < 0.7 * contextLimit) {
147
+ return {
148
+ messages: working,
149
+ compacted: dropped > 0,
150
+ droppedCount: dropped,
151
+ };
152
+ }
153
+ const out = working.slice();
154
+ const stopIdx = Math.max(0, out.length - KEEP_TAIL);
155
+ for (let i = 0; i < stopIdx; i++) {
156
+ if (out[i].role === "system")
157
+ continue;
158
+ if (!Array.isArray(out[i].content))
159
+ continue;
160
+ let local = false;
161
+ const next = out[i].content.map((part) => {
162
+ const r = elideToolResult(part);
163
+ if (r.changed)
164
+ local = true;
165
+ return r.part;
166
+ });
167
+ if (local) {
168
+ out[i] = { ...out[i], content: next };
169
+ dropped++;
170
+ if (approxBytes(out) / 4 < 0.6 * contextLimit)
171
+ break;
172
+ }
173
+ }
174
+ return {
175
+ messages: out,
176
+ compacted: dropped > 0,
177
+ droppedCount: dropped,
178
+ };
179
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./security.js";
2
+ export * from "./compact.js";
3
+ export * from "./redact.js";
4
+ export * from "./miniWindowGeometry.js";
@@ -0,0 +1,4 @@
1
+ export * from "./security.js";
2
+ export * from "./compact.js";
3
+ export * from "./redact.js";
4
+ export * from "./miniWindowGeometry.js";
@@ -0,0 +1,17 @@
1
+ export type Geom = {
2
+ x: number;
3
+ y: number;
4
+ w: number;
5
+ h: number;
6
+ };
7
+ export type Viewport = {
8
+ vw: number;
9
+ vh: number;
10
+ };
11
+ export type ResizeDir = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
12
+ export declare const MIN_W = 400;
13
+ export declare const MIN_H = 280;
14
+ export declare function defaultGeom(vp: Viewport): Geom;
15
+ export declare function clampGeom(g: Geom, vp: Viewport): Geom;
16
+ export declare function applyDrag(start: Geom, dx: number, dy: number, vp: Viewport): Geom;
17
+ export declare function applyResize(start: Geom, dir: ResizeDir, dx: number, dy: number, vp: Viewport): Geom;
@@ -0,0 +1,59 @@
1
+ export const MIN_W = 400;
2
+ export const MIN_H = 280;
3
+ const MARGIN_X = 16;
4
+ const BOTTOM_GAP = 96;
5
+ const TOP_GAP = 16;
6
+ const clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v;
7
+ export function defaultGeom(vp) {
8
+ const w = Math.max(MIN_W, Math.min(500, vp.vw - MARGIN_X * 2));
9
+ const h = Math.max(MIN_H, Math.min(600, vp.vh - BOTTOM_GAP - TOP_GAP));
10
+ return clampGeom({ x: vp.vw - w - MARGIN_X, y: vp.vh - h - BOTTOM_GAP, w, h }, vp);
11
+ }
12
+ export function clampGeom(g, vp) {
13
+ const w = clamp(g.w, MIN_W, Math.max(MIN_W, vp.vw));
14
+ const h = clamp(g.h, MIN_H, Math.max(MIN_H, vp.vh));
15
+ return {
16
+ w,
17
+ h,
18
+ x: clamp(g.x, 0, Math.max(0, vp.vw - w)),
19
+ y: clamp(g.y, 0, Math.max(0, vp.vh - h)),
20
+ };
21
+ }
22
+ export function applyDrag(start, dx, dy, vp) {
23
+ return clampGeom({ ...start, x: start.x + dx, y: start.y + dy }, vp);
24
+ }
25
+ export function applyResize(start, dir, dx, dy, vp) {
26
+ let left = start.x;
27
+ let top = start.y;
28
+ let right = start.x + start.w;
29
+ let bottom = start.y + start.h;
30
+ const movesW = dir.includes("w");
31
+ const movesE = dir.includes("e");
32
+ const movesN = dir.includes("n");
33
+ const movesS = dir.includes("s");
34
+ if (movesE)
35
+ right += dx;
36
+ if (movesW)
37
+ left += dx;
38
+ if (movesS)
39
+ bottom += dy;
40
+ if (movesN)
41
+ top += dy;
42
+ left = Math.max(0, left);
43
+ top = Math.max(0, top);
44
+ right = Math.min(vp.vw, right);
45
+ bottom = Math.min(vp.vh, bottom);
46
+ if (right - left < MIN_W) {
47
+ if (movesW)
48
+ left = right - MIN_W;
49
+ else
50
+ right = left + MIN_W;
51
+ }
52
+ if (bottom - top < MIN_H) {
53
+ if (movesN)
54
+ top = bottom - MIN_H;
55
+ else
56
+ bottom = top + MIN_H;
57
+ }
58
+ return clampGeom({ x: left, y: top, w: right - left, h: bottom - top }, vp);
59
+ }
@@ -0,0 +1 @@
1
+ export declare function redactSensitive(text: string): string;
@@ -0,0 +1,34 @@
1
+ const PATTERNS = [
2
+ { kind: "openai-key", re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g },
3
+ { kind: "anthropic-key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
4
+ { kind: "aws-access-key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
5
+ { kind: "github-token", re: /\bgh[opsur]_[A-Za-z0-9]{36,}\b/g },
6
+ { kind: "github-pat", re: /\bgithub_pat_[A-Za-z0-9_]{40,}\b/g },
7
+ { kind: "google-api-key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
8
+ { kind: "slack-token", re: /\bxox[bpsare]-[A-Za-z0-9-]{10,}\b/g },
9
+ {
10
+ kind: "stripe-key",
11
+ re: /\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{24,}\b/g,
12
+ },
13
+ {
14
+ kind: "jwt",
15
+ re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
16
+ },
17
+ { kind: "bearer", re: /\bBearer\s+[A-Za-z0-9._-]{20,}/g },
18
+ {
19
+ kind: "env-assign",
20
+ re: /\b((?:[A-Z][A-Z0-9_]*)?(?:API[_-]?KEY|SECRET(?:[_-]?KEY)?|ACCESS[_-]?TOKEN|AUTH[_-]?TOKEN|PASSWORD|PASSWD|PRIVATE[_-]?KEY|CLIENT[_-]?SECRET)[A-Z0-9_]*)\s*[:=]\s*(["']?)([^\s"';|&]+)\2/gi,
21
+ },
22
+ ];
23
+ export function redactSensitive(text) {
24
+ let out = text;
25
+ for (const { kind, re } of PATTERNS) {
26
+ if (kind === "env-assign") {
27
+ out = out.replace(re, (_m, name, q, _val) => `${name}=${q}<REDACTED>${q}`);
28
+ }
29
+ else {
30
+ out = out.replace(re, `<REDACTED:${kind}>`);
31
+ }
32
+ }
33
+ return out;
34
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Path-safety guards for AI tool calls.
3
+ *
4
+ * Goals:
5
+ * - Block reads of files that almost always contain secrets (.env*, *.pem,
6
+ * id_rsa*, .aws/credentials, .ssh/, .git/, kube/azure config, etc.).
7
+ * - Block writes/exec into the same set, plus directories where automated
8
+ * mutation is dangerous (system dirs, Windows system dirs).
9
+ *
10
+ * This is a *defense layer*, not a sandbox. The model may still be coaxed
11
+ * into doing something silly within allowed paths — the user-confirmation
12
+ * UI for write/exec is the real safety net. These checks ensure that
13
+ * read tools (which auto-approve) can never silently exfiltrate obvious
14
+ * secrets, and that a single bad approval can't blow up the system.
15
+ *
16
+ * Defense-in-depth notes:
17
+ * - Comparison surface is lowercased *only for matching*. Original path is
18
+ * preserved for basename pattern checks and error messages.
19
+ * - Windows drive prefix (e.g. `C:`) is stripped from the comparison form so
20
+ * Unix-style root prefix checks behave consistently on both platforms.
21
+ * - Protected directories match exact-equal-or-descendant, not raw
22
+ * substring-with-trailing-slash. Bare names (`/Users/me/.ssh`) and
23
+ * case-variants (`/Users/me/.SSH/config` on macOS/Windows case-insensitive
24
+ * filesystems) are caught.
25
+ * - The caller is expected to additionally validate the *canonical* path
26
+ * (post symlink resolution) via `getAiAdapter().native.canonicalize` + a second
27
+ * `checkReadable` pass, since a symlink at an "innocent" path can point
28
+ * into a protected directory.
29
+ */
30
+ export type SafetyResult = {
31
+ ok: true;
32
+ } | {
33
+ ok: false;
34
+ reason: string;
35
+ };
36
+ export declare function checkReadable(path: string): SafetyResult;
37
+ export declare function checkWritable(path: string): SafetyResult;
38
+ /**
39
+ * Lightweight heuristic for blocking obviously destructive shell commands
40
+ * even after the user has approved them. The approval UI shows the command
41
+ * verbatim, so the user is the primary gate; this just catches a couple of
42
+ * patterns that almost certainly indicate the model went off the rails.
43
+ */
44
+ /**
45
+ * Two-phase safety check that also defends against symlink traversal: first
46
+ * checks the literal path, then (if it exists) canonicalizes it via the
47
+ * native FS and re-checks the resolved path. A symlink at `./innocent.txt`
48
+ * pointing into `~/.ssh/id_rsa` is caught on the second pass.
49
+ *
50
+ * Returns the canonical path on success so callers can use it for the actual
51
+ * read — avoids TOCTOU between the safety check and the read.
52
+ */
53
+ export declare function checkReadableCanonical(path: string, canonicalize: (p: string) => Promise<string>): Promise<{
54
+ ok: true;
55
+ canonical: string;
56
+ } | {
57
+ ok: false;
58
+ reason: string;
59
+ }>;
60
+ /**
61
+ * Same pattern as {@link checkReadableCanonical} but for writes. The canonical
62
+ * path is only available if the file already exists — for new-file creates
63
+ * we additionally canonicalize the parent directory.
64
+ */
65
+ export declare function checkWritableCanonical(path: string, canonicalize: (p: string) => Promise<string>): Promise<{
66
+ ok: true;
67
+ canonical: string;
68
+ } | {
69
+ ok: false;
70
+ reason: string;
71
+ }>;
72
+ export declare function checkShellCommand(cmd: string): SafetyResult;