@ithousecall/chat 0.0.0-stage → 0.2.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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -2
  3. package/dist/attachments.d.ts +32 -0
  4. package/dist/attachments.js +159 -0
  5. package/dist/chat/adapter.d.ts +77 -0
  6. package/dist/chat/adapter.js +23 -0
  7. package/dist/chat/console.d.ts +21 -0
  8. package/dist/chat/console.js +105 -0
  9. package/dist/chat/download.d.ts +13 -0
  10. package/dist/chat/download.js +61 -0
  11. package/dist/chat/googlechat.d.ts +54 -0
  12. package/dist/chat/googlechat.js +329 -0
  13. package/dist/chat/http.d.ts +9 -0
  14. package/dist/chat/http.js +21 -0
  15. package/dist/chat/markdown.d.ts +4 -0
  16. package/dist/chat/markdown.js +28 -0
  17. package/dist/chat/slack.d.ts +55 -0
  18. package/dist/chat/slack.js +197 -0
  19. package/dist/chat/teams.d.ts +75 -0
  20. package/dist/chat/teams.js +202 -0
  21. package/dist/config.d.ts +79 -0
  22. package/dist/config.js +155 -0
  23. package/dist/docs/chunk.d.ts +7 -0
  24. package/dist/docs/chunk.js +70 -0
  25. package/dist/docs/confluence.d.ts +12 -0
  26. package/dist/docs/confluence.js +68 -0
  27. package/dist/docs/drive.d.ts +20 -0
  28. package/dist/docs/drive.js +85 -0
  29. package/dist/docs/html.d.ts +2 -0
  30. package/dist/docs/html.js +13 -0
  31. package/dist/docs/index.d.ts +31 -0
  32. package/dist/docs/index.js +146 -0
  33. package/dist/docs/local.d.ts +3 -0
  34. package/dist/docs/local.js +39 -0
  35. package/dist/docs/source.d.ts +14 -0
  36. package/dist/docs/source.js +1 -0
  37. package/dist/docs/sync.d.ts +13 -0
  38. package/dist/docs/sync.js +68 -0
  39. package/dist/docs/tools.d.ts +3 -0
  40. package/dist/docs/tools.js +59 -0
  41. package/dist/extensions/approvals.d.ts +26 -0
  42. package/dist/extensions/approvals.js +144 -0
  43. package/dist/extensions/mcp.d.ts +10 -0
  44. package/dist/extensions/mcp.js +127 -0
  45. package/dist/extensions/tool.d.ts +14 -0
  46. package/dist/extensions/tool.js +8 -0
  47. package/dist/format.d.ts +7 -0
  48. package/dist/format.js +36 -0
  49. package/dist/harness.d.ts +4 -0
  50. package/dist/harness.js +14 -0
  51. package/dist/index.d.ts +7 -0
  52. package/dist/index.js +5 -0
  53. package/dist/lock.d.ts +2 -0
  54. package/dist/lock.js +18 -0
  55. package/dist/model.d.ts +14 -0
  56. package/dist/model.js +87 -0
  57. package/dist/prompt.d.ts +2 -0
  58. package/dist/prompt.js +15 -0
  59. package/dist/start.d.ts +13 -0
  60. package/dist/start.js +148 -0
  61. package/dist/threads.d.ts +19 -0
  62. package/dist/threads.js +73 -0
  63. package/dist/turn.d.ts +12 -0
  64. package/dist/turn.js +206 -0
  65. package/package.json +62 -4
@@ -0,0 +1,20 @@
1
+ import type { DocsSource } from "./source.ts";
2
+ export type DriveFile = {
3
+ id: string;
4
+ name: string;
5
+ mimeType: string;
6
+ modifiedTime: string;
7
+ webViewLink: string;
8
+ };
9
+ /** The Drive calls the source makes, so its logic can be tested without Google. */
10
+ export interface DriveApi {
11
+ list(query: string, pageToken: string | undefined, signal: AbortSignal): Promise<{
12
+ files: DriveFile[];
13
+ nextPageToken?: string;
14
+ }>;
15
+ exportMarkdown(fileId: string, signal: AbortSignal): Promise<string>;
16
+ download(fileId: string, signal: AbortSignal): Promise<string>;
17
+ }
18
+ export declare function googleDriveApi(keyFile: string): DriveApi;
19
+ /** Google Docs and Markdown or text files in one Drive folder and everything below it. */
20
+ export declare function driveSource(name: string, folderId: string, api: DriveApi): DocsSource;
@@ -0,0 +1,85 @@
1
+ import { drive } from "@googleapis/drive";
2
+ import { GoogleAuth } from "google-auth-library";
3
+ const FOLDER = "application/vnd.google-apps.folder";
4
+ const GOOGLE_DOC = "application/vnd.google-apps.document";
5
+ const TEXT = new Set(["text/markdown", "text/plain", "text/x-markdown"]);
6
+ function text(data) {
7
+ if (typeof data !== "string")
8
+ throw new TypeError("Drive did not return text");
9
+ return data;
10
+ }
11
+ export function googleDriveApi(keyFile) {
12
+ const client = drive({
13
+ version: "v3",
14
+ auth: new GoogleAuth({ keyFile, scopes: ["https://www.googleapis.com/auth/drive.readonly"] }),
15
+ });
16
+ return {
17
+ list: async (q, pageToken, signal) => {
18
+ const { data } = await client.files.list({
19
+ q,
20
+ pageSize: 1000,
21
+ fields: "nextPageToken, files(id, name, mimeType, modifiedTime, webViewLink)",
22
+ supportsAllDrives: true,
23
+ includeItemsFromAllDrives: true,
24
+ ...(pageToken === undefined ? {} : { pageToken }),
25
+ }, { signal });
26
+ const files = (data.files ?? []).flatMap((file) => file.id && file.name && file.mimeType && file.modifiedTime && file.webViewLink
27
+ ? [
28
+ {
29
+ id: file.id,
30
+ name: file.name,
31
+ mimeType: file.mimeType,
32
+ modifiedTime: file.modifiedTime,
33
+ webViewLink: file.webViewLink,
34
+ },
35
+ ]
36
+ : []);
37
+ return data.nextPageToken ? { files, nextPageToken: data.nextPageToken } : { files };
38
+ },
39
+ exportMarkdown: async (fileId, signal) => text((await client.files.export({ fileId, mimeType: "text/markdown" }, { responseType: "text", signal })).data),
40
+ download: async (fileId, signal) => text((await client.files.get({ fileId, alt: "media", supportsAllDrives: true }, { responseType: "text", signal }))
41
+ .data),
42
+ };
43
+ }
44
+ /** Google Docs and Markdown or text files in one Drive folder and everything below it. */
45
+ export function driveSource(name, folderId, api) {
46
+ const mimeTypes = new Map();
47
+ return {
48
+ name,
49
+ list: async (signal) => {
50
+ const docs = [];
51
+ const folders = [folderId];
52
+ const seen = new Set(folders);
53
+ for (let folder = folders.pop(); folder !== undefined; folder = folders.pop()) {
54
+ let pageToken;
55
+ do {
56
+ const page = await api.list(`'${folder}' in parents and trashed = false`, pageToken, signal);
57
+ for (const file of page.files) {
58
+ if (file.mimeType === FOLDER) {
59
+ if (!seen.has(file.id))
60
+ folders.push(file.id);
61
+ seen.add(file.id);
62
+ }
63
+ else if (file.mimeType === GOOGLE_DOC || TEXT.has(file.mimeType)) {
64
+ mimeTypes.set(file.id, file.mimeType);
65
+ docs.push({
66
+ id: file.id,
67
+ title: file.name.replace(/\.(md|markdown|txt)$/i, ""),
68
+ url: file.webViewLink,
69
+ updatedAt: file.modifiedTime,
70
+ });
71
+ }
72
+ }
73
+ pageToken = page.nextPageToken;
74
+ } while (pageToken !== undefined);
75
+ }
76
+ return docs;
77
+ },
78
+ fetch: async (id, signal) => {
79
+ const mimeType = mimeTypes.get(id);
80
+ if (mimeType === undefined)
81
+ throw new Error(`Drive file ${id} was not listed`);
82
+ return mimeType === GOOGLE_DOC ? api.exportMarkdown(id, signal) : api.download(id, signal);
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,2 @@
1
+ /** Confluence storage format (XHTML with ac:/ri: macros) to Markdown: code macros become fences, macro parameters and images go. */
2
+ export declare function storageToMarkdown(storage: string): string;
@@ -0,0 +1,13 @@
1
+ import TurndownService from "turndown";
2
+ import { escapeHtml } from "../format.js";
3
+ const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", bulletListMarker: "-" });
4
+ const CODE_MACRO = /<ac:structured-macro[^>]*ac:name="code"[^>]*>[\s\S]*?<ac:plain-text-body><!\[CDATA\[([\s\S]*?)\]\]><\/ac:plain-text-body>[\s\S]*?<\/ac:structured-macro>/g;
5
+ /** Confluence storage format (XHTML with ac:/ri: macros) to Markdown: code macros become fences, macro parameters and images go. */
6
+ export function storageToMarkdown(storage) {
7
+ const html = storage
8
+ .replaceAll(CODE_MACRO, (_match, code) => `<pre><code>${escapeHtml(code)}</code></pre>`)
9
+ .replaceAll(/<!\[CDATA\[([\s\S]*?)\]\]>/g, (_match, text) => escapeHtml(text))
10
+ .replaceAll(/<ac:parameter[^>]*>[\s\S]*?<\/ac:parameter>/g, "")
11
+ .replaceAll(/<ac:image[\s\S]*?<\/ac:image>/g, "");
12
+ return turndown.turndown(html);
13
+ }
@@ -0,0 +1,31 @@
1
+ import type { DocMeta } from "./source.ts";
2
+ export type SearchHit = {
3
+ docKey: string;
4
+ title: string;
5
+ url: string;
6
+ heading: string;
7
+ snippet: string;
8
+ };
9
+ type IndexedDoc = {
10
+ docKey: string;
11
+ title: string;
12
+ url: string;
13
+ text: string;
14
+ };
15
+ /** Each meaningful word of the query as a quoted FTS5 term, OR-joined, so punctuation cannot break the syntax. */
16
+ export declare function ftsQuery(query: string): string;
17
+ export declare class DocsIndex {
18
+ #private;
19
+ constructor(path: string);
20
+ close(): void;
21
+ /** `updatedAt` of every indexed document of a source, by document ID. */
22
+ versions(source: string): Map<string, string>;
23
+ upsert(source: string, doc: DocMeta, body: string): void;
24
+ remove(source: string, id: string): void;
25
+ /** Drop every document of a source that is no longer configured; returns how many. */
26
+ removeSourcesExcept(sources: readonly string[]): number;
27
+ search(query: string, limit: number): SearchHit[];
28
+ read(docKey: string): IndexedDoc | undefined;
29
+ recordSync(source: string, error: string | null): void;
30
+ }
31
+ export {};
@@ -0,0 +1,146 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { chunkMarkdown } from "./chunk.js";
3
+ const SCHEMA = `
4
+ CREATE TABLE IF NOT EXISTS documents (
5
+ key TEXT PRIMARY KEY,
6
+ source TEXT NOT NULL,
7
+ title TEXT NOT NULL,
8
+ url TEXT NOT NULL,
9
+ updated_at TEXT NOT NULL,
10
+ text TEXT NOT NULL
11
+ );
12
+ CREATE INDEX IF NOT EXISTS documents_source ON documents (source);
13
+ CREATE TABLE IF NOT EXISTS chunks (
14
+ id INTEGER PRIMARY KEY,
15
+ doc_key TEXT NOT NULL,
16
+ heading TEXT NOT NULL,
17
+ text TEXT NOT NULL
18
+ );
19
+ CREATE INDEX IF NOT EXISTS chunks_doc_key ON chunks (doc_key);
20
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, heading, content='chunks', content_rowid='id');
21
+ CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
22
+ INSERT INTO chunks_fts (rowid, text, heading) VALUES (new.id, new.text, new.heading);
23
+ END;
24
+ CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
25
+ INSERT INTO chunks_fts (chunks_fts, rowid, text, heading) VALUES ('delete', old.id, old.text, old.heading);
26
+ END;
27
+ CREATE TABLE IF NOT EXISTS sync_state (
28
+ source TEXT PRIMARY KEY,
29
+ last_sync_at TEXT,
30
+ last_error TEXT
31
+ );
32
+ `;
33
+ const STOPWORDS = new Set("a an and are as at be but by can could do does for from get how i if in is it me my of on or our should so that the this to was we what when where which who why will with would you your".split(" "));
34
+ /** Each meaningful word of the query as a quoted FTS5 term, OR-joined, so punctuation cannot break the syntax. */
35
+ export function ftsQuery(query) {
36
+ const words = (query.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).filter((word) => !STOPWORDS.has(word));
37
+ return [...new Set(words)].map((word) => `"${word}"`).join(" OR ");
38
+ }
39
+ function text(value) {
40
+ if (typeof value !== "string")
41
+ throw new TypeError(`expected a string column, got ${typeof value}`);
42
+ return value;
43
+ }
44
+ export class DocsIndex {
45
+ #db;
46
+ constructor(path) {
47
+ this.#db = new DatabaseSync(path);
48
+ this.#db.exec("PRAGMA journal_mode = WAL");
49
+ this.#db.exec(SCHEMA);
50
+ }
51
+ close() {
52
+ this.#db.close();
53
+ }
54
+ /** `updatedAt` of every indexed document of a source, by document ID. */
55
+ versions(source) {
56
+ const rows = this.#db.prepare("SELECT key, updated_at FROM documents WHERE source = ?").all(source);
57
+ const prefix = `${source}:`;
58
+ return new Map(rows.map((row) => [text(row.key).slice(prefix.length), text(row.updated_at)]));
59
+ }
60
+ upsert(source, doc, body) {
61
+ const key = `${source}:${doc.id}`;
62
+ this.#transaction(() => {
63
+ this.#removeKey(key);
64
+ this.#db
65
+ .prepare("INSERT INTO documents (key, source, title, url, updated_at, text) VALUES (?, ?, ?, ?, ?, ?)")
66
+ .run(key, source, doc.title, doc.url, doc.updatedAt, body);
67
+ const insert = this.#db.prepare("INSERT INTO chunks (doc_key, heading, text) VALUES (?, ?, ?)");
68
+ for (const chunk of chunkMarkdown(doc.title, body))
69
+ insert.run(key, chunk.heading, chunk.text);
70
+ });
71
+ }
72
+ remove(source, id) {
73
+ this.#transaction(() => this.#removeKey(`${source}:${id}`));
74
+ }
75
+ /** Drop every document of a source that is no longer configured; returns how many. */
76
+ removeSourcesExcept(sources) {
77
+ const keep = JSON.stringify(sources);
78
+ let removed = 0;
79
+ this.#transaction(() => {
80
+ this.#db
81
+ .prepare("DELETE FROM chunks WHERE doc_key IN (SELECT key FROM documents WHERE source NOT IN (SELECT value FROM json_each(?)))")
82
+ .run(keep);
83
+ removed = Number(this.#db.prepare("DELETE FROM documents WHERE source NOT IN (SELECT value FROM json_each(?))").run(keep)
84
+ .changes);
85
+ this.#db.prepare("DELETE FROM sync_state WHERE source NOT IN (SELECT value FROM json_each(?))").run(keep);
86
+ });
87
+ return removed;
88
+ }
89
+ search(query, limit) {
90
+ const match = ftsQuery(query);
91
+ if (match === "")
92
+ return [];
93
+ const rows = this.#db
94
+ .prepare(`SELECT c.doc_key, d.title, d.url, c.heading, snippet(chunks_fts, 0, '', '', '…', 40) AS snippet
95
+ FROM chunks_fts
96
+ JOIN chunks c ON c.id = chunks_fts.rowid
97
+ JOIN documents d ON d.key = c.doc_key
98
+ WHERE chunks_fts MATCH ?
99
+ ORDER BY bm25(chunks_fts, 1.0, 2.0)
100
+ LIMIT 200`)
101
+ .all(match);
102
+ const best = new Map();
103
+ for (const row of rows) {
104
+ const docKey = text(row.doc_key);
105
+ if (best.has(docKey))
106
+ continue;
107
+ best.set(docKey, {
108
+ docKey,
109
+ title: text(row.title),
110
+ url: text(row.url),
111
+ heading: text(row.heading),
112
+ snippet: text(row.snippet).replaceAll(/\s+/g, " ").trim(),
113
+ });
114
+ if (best.size === limit)
115
+ break;
116
+ }
117
+ return [...best.values()];
118
+ }
119
+ read(docKey) {
120
+ const row = this.#db.prepare("SELECT key, title, url, text FROM documents WHERE key = ?").get(docKey);
121
+ if (row === undefined)
122
+ return undefined;
123
+ return { docKey: text(row.key), title: text(row.title), url: text(row.url), text: text(row.text) };
124
+ }
125
+ recordSync(source, error) {
126
+ this.#db
127
+ .prepare(`INSERT INTO sync_state (source, last_sync_at, last_error) VALUES (?, ?, ?)
128
+ ON CONFLICT (source) DO UPDATE SET last_sync_at = excluded.last_sync_at, last_error = excluded.last_error`)
129
+ .run(source, new Date().toISOString(), error);
130
+ }
131
+ #removeKey(key) {
132
+ this.#db.prepare("DELETE FROM chunks WHERE doc_key = ?").run(key);
133
+ this.#db.prepare("DELETE FROM documents WHERE key = ?").run(key);
134
+ }
135
+ #transaction(work) {
136
+ this.#db.exec("BEGIN");
137
+ try {
138
+ work();
139
+ this.#db.exec("COMMIT");
140
+ }
141
+ catch (error) {
142
+ this.#db.exec("ROLLBACK");
143
+ throw error;
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,3 @@
1
+ import type { DocsSource } from "./source.ts";
2
+ /** Markdown and text files under one folder; the folder is the scope. */
3
+ export declare function localSource(name: string, root: string, urlBase: string | undefined): DocsSource;
@@ -0,0 +1,39 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { basename, extname, join, relative, sep } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ const EXTENSIONS = new Set([".md", ".markdown", ".txt"]);
5
+ async function walk(dir) {
6
+ const entries = await readdir(dir, { withFileTypes: true });
7
+ const nested = await Promise.all(entries
8
+ .filter((entry) => !entry.name.startsWith("."))
9
+ .map(async (entry) => {
10
+ const path = join(dir, entry.name);
11
+ if (entry.isDirectory())
12
+ return walk(path);
13
+ return entry.isFile() && EXTENSIONS.has(extname(entry.name).toLowerCase()) ? [path] : [];
14
+ }));
15
+ return nested.flat();
16
+ }
17
+ function title(text, path) {
18
+ return /^#[ \t]+(.+?)[ \t]*#*[ \t]*$/m.exec(text)?.[1] ?? basename(path, extname(path));
19
+ }
20
+ /** Markdown and text files under one folder; the folder is the scope. */
21
+ export function localSource(name, root, urlBase) {
22
+ return {
23
+ name,
24
+ list: async () => {
25
+ const files = (await walk(root)).sort();
26
+ return Promise.all(files.map(async (path) => {
27
+ const id = relative(root, path).split(sep).join("/");
28
+ const [text, info] = await Promise.all([readFile(path, "utf8"), stat(path)]);
29
+ return {
30
+ id,
31
+ title: title(text, path),
32
+ url: urlBase === undefined ? pathToFileURL(path).href : `${urlBase.replace(/\/+$/, "")}/${encodeURI(id)}`,
33
+ updatedAt: info.mtime.toISOString(),
34
+ };
35
+ }));
36
+ },
37
+ fetch: (id) => readFile(join(root, ...id.split("/")), "utf8"),
38
+ };
39
+ }
@@ -0,0 +1,14 @@
1
+ export type DocMeta = {
2
+ id: string;
3
+ title: string;
4
+ url: string;
5
+ updatedAt: string;
6
+ };
7
+ /** One place docs come from. Documents are keyed `${name}:${id}` in the index. */
8
+ export interface DocsSource {
9
+ readonly name: string;
10
+ /** Every document in scope, metadata only. */
11
+ list(signal: AbortSignal): Promise<DocMeta[]>;
12
+ /** One document's text as Markdown or plain text. */
13
+ fetch(id: string, signal: AbortSignal): Promise<string>;
14
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import type { DocsIndex } from "./index.ts";
2
+ import type { DocsSource } from "./source.ts";
3
+ type SyncResult = {
4
+ indexed: number;
5
+ removed: number;
6
+ failed: number;
7
+ };
8
+ /** Bring the index in line with one source: fetch only changed documents, drop the ones that left its scope. */
9
+ export declare function syncSource(index: DocsIndex, source: DocsSource, signal: AbortSignal): Promise<SyncResult>;
10
+ export declare function syncAll(index: DocsIndex, sources: readonly DocsSource[], signal: AbortSignal): Promise<void>;
11
+ /** Sync now and every `intervalMinutes`; the returned stop function resolves once no sync is running. */
12
+ export declare function startDocsSync(index: DocsIndex, sources: readonly DocsSource[], intervalMinutes: number): () => Promise<void>;
13
+ export {};
@@ -0,0 +1,68 @@
1
+ /** Bring the index in line with one source: fetch only changed documents, drop the ones that left its scope. */
2
+ export async function syncSource(index, source, signal) {
3
+ const docs = await source.list(signal);
4
+ const known = index.versions(source.name);
5
+ const result = { indexed: 0, removed: 0, failed: 0 };
6
+ for (const doc of docs) {
7
+ if (known.get(doc.id) === doc.updatedAt)
8
+ continue;
9
+ try {
10
+ index.upsert(source.name, doc, await source.fetch(doc.id, signal));
11
+ result.indexed++;
12
+ }
13
+ catch (error) {
14
+ if (signal.aborted)
15
+ throw error;
16
+ result.failed++;
17
+ console.error(`[docs] ${source.name}: could not fetch ${doc.title} (${doc.id})`, error);
18
+ }
19
+ }
20
+ const listed = new Set(docs.map((doc) => doc.id));
21
+ for (const id of known.keys()) {
22
+ if (listed.has(id))
23
+ continue;
24
+ index.remove(source.name, id);
25
+ result.removed++;
26
+ }
27
+ return result;
28
+ }
29
+ export async function syncAll(index, sources, signal) {
30
+ const removed = index.removeSourcesExcept(sources.map((source) => source.name));
31
+ if (removed > 0)
32
+ console.log(`[docs] removed ${removed} documents of sources no longer configured`);
33
+ for (const source of sources) {
34
+ try {
35
+ const result = await syncSource(index, source, signal);
36
+ index.recordSync(source.name, result.failed === 0 ? null : `${result.failed} documents failed to fetch`);
37
+ console.log(`[docs] ${source.name}: ${result.indexed} indexed, ${result.removed} removed, ${result.failed} failed`);
38
+ }
39
+ catch (error) {
40
+ if (signal.aborted)
41
+ return;
42
+ index.recordSync(source.name, error instanceof Error ? error.message : String(error));
43
+ console.error(`[docs] ${source.name}: sync failed`, error);
44
+ }
45
+ }
46
+ }
47
+ /** Sync now and every `intervalMinutes`; the returned stop function resolves once no sync is running. */
48
+ export function startDocsSync(index, sources, intervalMinutes) {
49
+ const controller = new AbortController();
50
+ let busy = true;
51
+ const sync = () => {
52
+ busy = true;
53
+ return syncAll(index, sources, controller.signal).finally(() => {
54
+ busy = false;
55
+ });
56
+ };
57
+ let running = sync();
58
+ const timer = setInterval(() => {
59
+ if (!busy)
60
+ running = sync();
61
+ }, intervalMinutes * 60_000);
62
+ timer.unref();
63
+ return async () => {
64
+ clearInterval(timer);
65
+ controller.abort();
66
+ await running;
67
+ };
68
+ }
@@ -0,0 +1,3 @@
1
+ import { type Extension } from "@earendil-works/pi-durable";
2
+ import type { DocsIndex } from "./index.ts";
3
+ export declare function docsExtension(index: DocsIndex): Extension;
@@ -0,0 +1,59 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { defineExtension, section } from "@earendil-works/pi-durable";
3
+ import { defineTool } from "../extensions/tool.js";
4
+ const READ_LIMIT = 30_000;
5
+ function formatHits(hits) {
6
+ if (hits.length === 0)
7
+ return "No documents matched. Try other words, or say the docs do not cover this.";
8
+ const lines = hits.map((hit, index) => [
9
+ `${index + 1}. ${hit.title}${hit.heading.includes(" > ") ? ` (${hit.heading.split(" > ").slice(1).join(" > ")})` : ""}`,
10
+ ` doc: ${hit.docKey}`,
11
+ ` url: ${hit.url}`,
12
+ ` ${hit.snippet}`,
13
+ ].join("\n"));
14
+ return lines.join("\n\n");
15
+ }
16
+ export function docsExtension(index) {
17
+ return defineExtension({
18
+ name: "docs",
19
+ tools: [
20
+ defineTool({
21
+ name: "search_docs",
22
+ description: "Search the company's IT documentation (runbooks, how-tos, policies). Returns the best matching documents with a snippet and link.",
23
+ parameters: Type.Object({
24
+ query: Type.String({ description: "Keywords describing the problem or task" }),
25
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10, description: "How many documents, default 5" })),
26
+ }),
27
+ execute: async (args) => ({
28
+ output: [{ type: "text", text: formatHits(index.search(args.query, args.limit ?? 5)) }],
29
+ }),
30
+ }),
31
+ defineTool({
32
+ name: "read_doc",
33
+ description: "Read one document found with search_docs, by its doc key.",
34
+ parameters: Type.Object({
35
+ doc: Type.String({ description: "The doc key from search_docs, like runbooks:vpn.md" }),
36
+ }),
37
+ execute: async (args) => {
38
+ const doc = index.read(args.doc);
39
+ if (doc === undefined) {
40
+ return {
41
+ output: [{ type: "text", text: `No document ${args.doc}. Use search_docs first.` }],
42
+ isError: true,
43
+ };
44
+ }
45
+ const truncated = doc.text.length > READ_LIMIT;
46
+ const body = truncated
47
+ ? `${doc.text.slice(0, READ_LIMIT)}\n\n[truncated; the full document is at the link]`
48
+ : doc.text;
49
+ return { output: [{ type: "text", text: `# ${doc.title}\nurl: ${doc.url}\n\n${body}` }] };
50
+ },
51
+ }),
52
+ ],
53
+ sections: [
54
+ section("docs", () => "Before answering a how-to or troubleshooting question, call search_docs, and read_doc when a snippet is not enough. " +
55
+ "Base the answer on what the docs say and cite each document you used as a Markdown link [title](url). " +
56
+ "If the docs do not cover it, say so instead of guessing."),
57
+ ],
58
+ });
59
+ }
@@ -0,0 +1,26 @@
1
+ import type { Static, TSchema } from "@earendil-works/pi-ai";
2
+ import { type Harness, type ToolRegistration } from "@earendil-works/pi-durable";
3
+ import { type ApprovalDecision, type ApprovalFeedback, type ApprovalOutcome, type ApprovalPrompt, type ChatAdapter, type ChatUser, type Platform } from "../chat/adapter.ts";
4
+ type ApprovalSeed = {
5
+ prompt: ApprovalPrompt;
6
+ conversationId: number;
7
+ createdAt: number;
8
+ };
9
+ export type Approval = ApprovalSeed & {
10
+ status: "pending" | ApprovalOutcome;
11
+ decidedBy: ChatUser | null;
12
+ };
13
+ type ApprovalTransport = {
14
+ adapters: ReadonlyMap<Platform, ChatAdapter>;
15
+ timeoutMs: number;
16
+ };
17
+ /** Write tools ask for approval through these adapters; set once at startup. */
18
+ export declare function setApprovalTransport(next: ApprovalTransport): void;
19
+ /**
20
+ * A tool that runs only after its requester, the author of the message that led to the call, confirms it in chat.
21
+ * Replay-safe: after a restart the call reruns, finds its approval and posted prompt, and keeps waiting.
22
+ */
23
+ export declare function withApproval<P extends TSchema>(tool: ToolRegistration<P>, summarize: (args: Static<P>) => string): ToolRegistration<P>;
24
+ /** Records the requester's decision; anyone else's click, or a late one, changes nothing. */
25
+ export declare function resolveApproval(harness: Harness, decision: ApprovalDecision): Promise<ApprovalFeedback>;
26
+ export {};