@plico-ai/runtime 0.0.0-reserved.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/NOTICE +4 -0
  2. package/README.md +9 -3
  3. package/dist/artifacts.d.ts +10 -0
  4. package/dist/artifacts.js +106 -0
  5. package/dist/engine/context.d.ts +16 -0
  6. package/dist/engine/context.js +77 -0
  7. package/dist/engine/engine.d.ts +12 -0
  8. package/dist/engine/engine.js +235 -0
  9. package/dist/engine/errors.d.ts +12 -0
  10. package/dist/engine/errors.js +19 -0
  11. package/dist/engine/index.d.ts +7 -0
  12. package/dist/engine/index.js +5 -0
  13. package/dist/engine/journal.d.ts +65 -0
  14. package/dist/engine/journal.js +1 -0
  15. package/dist/engine/memory-journal.d.ts +2 -0
  16. package/dist/engine/memory-journal.js +125 -0
  17. package/dist/engine/sqlite-engine.d.ts +7 -0
  18. package/dist/engine/sqlite-engine.js +6 -0
  19. package/dist/engine/sqlite-journal.d.ts +5 -0
  20. package/dist/engine/sqlite-journal.js +136 -0
  21. package/dist/engine/types.d.ts +97 -0
  22. package/dist/engine/types.js +1 -0
  23. package/dist/env.d.ts +7 -0
  24. package/dist/env.js +19 -0
  25. package/dist/errors.d.ts +18 -0
  26. package/dist/errors.js +40 -0
  27. package/dist/evals/discover.d.ts +3 -0
  28. package/dist/evals/discover.js +49 -0
  29. package/dist/evals/index.d.ts +3 -0
  30. package/dist/evals/index.js +3 -0
  31. package/dist/evals/promote.d.ts +7 -0
  32. package/dist/evals/promote.js +32 -0
  33. package/dist/evals/run-evals.d.ts +2 -0
  34. package/dist/evals/run-evals.js +69 -0
  35. package/dist/events.d.ts +28 -0
  36. package/dist/events.js +104 -0
  37. package/dist/guards.d.ts +1 -0
  38. package/dist/guards.js +3 -0
  39. package/dist/index.d.ts +18 -0
  40. package/dist/index.js +17 -0
  41. package/dist/instructions.d.ts +4 -0
  42. package/dist/instructions.js +23 -0
  43. package/dist/policy/approval.d.ts +8 -0
  44. package/dist/policy/approval.js +27 -0
  45. package/dist/providers/ai-sdk.d.ts +10 -0
  46. package/dist/providers/ai-sdk.js +154 -0
  47. package/dist/providers/configured.d.ts +17 -0
  48. package/dist/providers/configured.js +60 -0
  49. package/dist/providers/openai-compatible.d.ts +8 -0
  50. package/dist/providers/openai-compatible.js +14 -0
  51. package/dist/providers/project-module.d.ts +7 -0
  52. package/dist/providers/project-module.js +29 -0
  53. package/dist/providers/registry.d.ts +20 -0
  54. package/dist/providers/registry.js +53 -0
  55. package/dist/readiness/check-readiness.d.ts +2 -0
  56. package/dist/readiness/check-readiness.js +33 -0
  57. package/dist/readiness/checks/approval-policy.d.ts +5 -0
  58. package/dist/readiness/checks/approval-policy.js +19 -0
  59. package/dist/readiness/checks/artifact-access.d.ts +3 -0
  60. package/dist/readiness/checks/artifact-access.js +5 -0
  61. package/dist/readiness/checks/artifacts.d.ts +6 -0
  62. package/dist/readiness/checks/artifacts.js +22 -0
  63. package/dist/readiness/checks/env.d.ts +3 -0
  64. package/dist/readiness/checks/env.js +10 -0
  65. package/dist/readiness/checks/evals.d.ts +2 -0
  66. package/dist/readiness/checks/evals.js +53 -0
  67. package/dist/readiness/checks/persistence.d.ts +3 -0
  68. package/dist/readiness/checks/persistence.js +61 -0
  69. package/dist/readiness/checks/project.d.ts +7 -0
  70. package/dist/readiness/checks/project.js +21 -0
  71. package/dist/readiness/checks/provider.d.ts +2 -0
  72. package/dist/readiness/checks/provider.js +20 -0
  73. package/dist/readiness/checks/server-config.d.ts +2 -0
  74. package/dist/readiness/checks/server-config.js +13 -0
  75. package/dist/readiness/checks/studio.d.ts +2 -0
  76. package/dist/readiness/checks/studio.js +16 -0
  77. package/dist/readiness/checks/thread-store.d.ts +3 -0
  78. package/dist/readiness/checks/thread-store.js +12 -0
  79. package/dist/readiness/checks/tools.d.ts +3 -0
  80. package/dist/readiness/checks/tools.js +42 -0
  81. package/dist/readiness/diagnostic.d.ts +2 -0
  82. package/dist/readiness/diagnostic.js +7 -0
  83. package/dist/readiness/index.d.ts +1 -0
  84. package/dist/readiness/index.js +1 -0
  85. package/dist/readiness/open-database.d.ts +8 -0
  86. package/dist/readiness/open-database.js +15 -0
  87. package/dist/run/artifact-tool-context.d.ts +19 -0
  88. package/dist/run/artifact-tool-context.js +74 -0
  89. package/dist/run/finalize.d.ts +16 -0
  90. package/dist/run/finalize.js +11 -0
  91. package/dist/run/index.d.ts +1 -0
  92. package/dist/run/index.js +1 -0
  93. package/dist/run/pinned-instructions.d.ts +18 -0
  94. package/dist/run/pinned-instructions.js +36 -0
  95. package/dist/run/record-tools.d.ts +8 -0
  96. package/dist/run/record-tools.js +21 -0
  97. package/dist/run/run-project.d.ts +17 -0
  98. package/dist/run/run-project.js +222 -0
  99. package/dist/run/strategies/approval.d.ts +9 -0
  100. package/dist/run/strategies/approval.js +42 -0
  101. package/dist/run/strategies/default-dry.d.ts +3 -0
  102. package/dist/run/strategies/default-dry.js +5 -0
  103. package/dist/run/strategies/provider.d.ts +6 -0
  104. package/dist/run/strategies/provider.js +193 -0
  105. package/dist/run/strategies/scripted.d.ts +5 -0
  106. package/dist/run/strategies/scripted.js +106 -0
  107. package/dist/run/tool-fingerprint.d.ts +2 -0
  108. package/dist/run/tool-fingerprint.js +26 -0
  109. package/dist/run/tool-step.d.ts +4 -0
  110. package/dist/run/tool-step.js +62 -0
  111. package/dist/run/tracing.d.ts +12 -0
  112. package/dist/run/tracing.js +92 -0
  113. package/dist/script.d.ts +4 -0
  114. package/dist/script.js +51 -0
  115. package/dist/storage/index.d.ts +1 -0
  116. package/dist/storage/index.js +1 -0
  117. package/dist/storage/metadata.d.ts +5 -0
  118. package/dist/storage/metadata.js +26 -0
  119. package/dist/storage/sqlite/append-event.d.ts +3 -0
  120. package/dist/storage/sqlite/append-event.js +108 -0
  121. package/dist/storage/sqlite/driver.d.ts +9 -0
  122. package/dist/storage/sqlite/driver.js +13 -0
  123. package/dist/storage/sqlite/index.d.ts +1 -0
  124. package/dist/storage/sqlite/index.js +1 -0
  125. package/dist/storage/sqlite/open.d.ts +3 -0
  126. package/dist/storage/sqlite/open.js +54 -0
  127. package/dist/storage/sqlite/owner-lock.d.ts +5 -0
  128. package/dist/storage/sqlite/owner-lock.js +55 -0
  129. package/dist/storage/sqlite/project-thread.d.ts +4 -0
  130. package/dist/storage/sqlite/project-thread.js +33 -0
  131. package/dist/storage/sqlite/queries.d.ts +9 -0
  132. package/dist/storage/sqlite/queries.js +134 -0
  133. package/dist/storage/sqlite/recover-interrupted.d.ts +2 -0
  134. package/dist/storage/sqlite/recover-interrupted.js +46 -0
  135. package/dist/storage/sqlite/schema.d.ts +10 -0
  136. package/dist/storage/sqlite/schema.js +205 -0
  137. package/dist/storage/sqlite/store.d.ts +2 -0
  138. package/dist/storage/sqlite/store.js +51 -0
  139. package/dist/storage/sqlite/thread-log.d.ts +4 -0
  140. package/dist/storage/sqlite/thread-log.js +9 -0
  141. package/dist/storage/sqlite/thread-title.d.ts +24 -0
  142. package/dist/storage/sqlite/thread-title.js +85 -0
  143. package/dist/storage/sqlite/threads.d.ts +47 -0
  144. package/dist/storage/sqlite/threads.js +156 -0
  145. package/dist/threads/stopwords.d.ts +1 -0
  146. package/dist/threads/stopwords.js +16 -0
  147. package/dist/threads/suggest-title.d.ts +9 -0
  148. package/dist/threads/suggest-title.js +102 -0
  149. package/dist/threads/titler.d.ts +10 -0
  150. package/dist/threads/titler.js +63 -0
  151. package/dist/threads/words.d.ts +12 -0
  152. package/dist/threads/words.js +39 -0
  153. package/dist/tools/artifact-model-tool-operations.d.ts +29 -0
  154. package/dist/tools/artifact-model-tool-operations.js +124 -0
  155. package/dist/tools/artifact-model-tools.d.ts +2 -0
  156. package/dist/tools/artifact-model-tools.js +101 -0
  157. package/dist/tools/discover.d.ts +4 -0
  158. package/dist/tools/discover.js +34 -0
  159. package/dist/tools/index.d.ts +3 -0
  160. package/dist/tools/index.js +3 -0
  161. package/dist/tools/load.d.ts +2 -0
  162. package/dist/tools/load.js +83 -0
  163. package/dist/tools/normalize.d.ts +2 -0
  164. package/dist/tools/normalize.js +48 -0
  165. package/dist/tools/schema.d.ts +7 -0
  166. package/dist/tools/schema.js +101 -0
  167. package/dist/tools/skill-model-tool.d.ts +7 -0
  168. package/dist/tools/skill-model-tool.js +38 -0
  169. package/dist/types/artifacts.d.ts +5 -0
  170. package/dist/types/artifacts.js +1 -0
  171. package/dist/types/evals.d.ts +16 -0
  172. package/dist/types/evals.js +1 -0
  173. package/dist/types/index.d.ts +10 -0
  174. package/dist/types/index.js +1 -0
  175. package/dist/types/provider.d.ts +45 -0
  176. package/dist/types/provider.js +1 -0
  177. package/dist/types/readiness.d.ts +9 -0
  178. package/dist/types/readiness.js +1 -0
  179. package/dist/types/run.d.ts +28 -0
  180. package/dist/types/run.js +1 -0
  181. package/dist/types/script.d.ts +16 -0
  182. package/dist/types/script.js +1 -0
  183. package/dist/types/store.d.ts +24 -0
  184. package/dist/types/store.js +1 -0
  185. package/dist/types/tools.d.ts +15 -0
  186. package/dist/types/tools.js +1 -0
  187. package/package.json +67 -8
@@ -0,0 +1,85 @@
1
+ import { suggestThreadTitle } from "../../threads/suggest-title.js";
2
+ import { appendThreadEvent } from "./thread-log.js";
3
+ /** A thread is retitled after each of its first exchanges, then the title is left alone. */
4
+ const retitledExchanges = 3;
5
+ /** Sets the title a user chose. It is never replaced by a generated one afterwards. */
6
+ export function setUserTitle(db, threadId, title, timestamp) {
7
+ db.prepare("UPDATE threads SET title = ?, title_source = 'user' WHERE thread_id = ?").run(title, threadId);
8
+ appendThreadEvent(db, threadId, "thread.titled", { title, source: "user" }, timestamp);
9
+ }
10
+ function messageCount(db, threadId) {
11
+ const row = db
12
+ .prepare("SELECT COUNT(*) AS count FROM thread_messages WHERE thread_id = ?")
13
+ .get(threadId);
14
+ return row.count;
15
+ }
16
+ function isFrozen(db, threadId) {
17
+ const thread = db
18
+ .prepare("SELECT title_source AS source FROM threads WHERE thread_id = ?")
19
+ .get(threadId);
20
+ if (!thread || thread.source === "user")
21
+ return true;
22
+ // Count first: a frozen thread must not load its transcript on every reply.
23
+ const replies = db
24
+ .prepare("SELECT COUNT(*) AS count FROM thread_messages WHERE thread_id = ? AND role = 'assistant'")
25
+ .get(threadId);
26
+ return replies.count > retitledExchanges;
27
+ }
28
+ /** The transcript to title now, or nothing when the thread is user-titled or past its exchanges. */
29
+ export function getTitleRequest(db, threadId) {
30
+ if (isFrozen(db, threadId))
31
+ return undefined;
32
+ const messages = db
33
+ .prepare("SELECT role, content FROM thread_messages WHERE thread_id = ? ORDER BY sequence")
34
+ .all(threadId);
35
+ return { revision: messageCount(db, threadId), messages };
36
+ }
37
+ /**
38
+ * Stores a title a titler wrote after a reply. It never replaces a user's title, stops after the
39
+ * first exchanges like the keyword titler, and rejects a title written from an older transcript
40
+ * than the one behind the stored title. Returns whether the title changed.
41
+ */
42
+ export function setGeneratedTitle(db, threadId, title, revision, timestamp) {
43
+ const thread = db
44
+ .prepare("SELECT title, title_revision AS stored FROM threads WHERE thread_id = ? AND title_source IS NOT 'user'")
45
+ .get(threadId);
46
+ if (!thread || isFrozen(db, threadId) || revision < thread.stored)
47
+ return false;
48
+ db.prepare("UPDATE threads SET title_revision = ? WHERE thread_id = ?").run(revision, threadId);
49
+ if (thread.title === title)
50
+ return false;
51
+ db.prepare("UPDATE threads SET title = ?, title_source = 'generated' WHERE thread_id = ?").run(title, threadId);
52
+ appendThreadEvent(db, threadId, "thread.titled", { title, source: "generated" }, timestamp);
53
+ return true;
54
+ }
55
+ /**
56
+ * Names the thread from its transcript. Called inside the transaction that stores a reply, where
57
+ * a title is only a convenience: if naming fails, the reply is still stored and the run completes.
58
+ */
59
+ export function retitleThread(db, threadId, timestamp) {
60
+ db.exec("SAVEPOINT retitle");
61
+ try {
62
+ nameThread(db, threadId, timestamp);
63
+ }
64
+ catch {
65
+ db.exec("ROLLBACK TO retitle");
66
+ }
67
+ finally {
68
+ db.exec("RELEASE retitle");
69
+ }
70
+ }
71
+ function nameThread(db, threadId, timestamp) {
72
+ const thread = db
73
+ .prepare("SELECT title, title_source AS source FROM threads WHERE thread_id = ?")
74
+ .get(threadId);
75
+ if (!thread || thread.source === "user")
76
+ return;
77
+ const request = getTitleRequest(db, threadId);
78
+ if (!request)
79
+ return;
80
+ const title = suggestThreadTitle(request.messages);
81
+ if (title === undefined || title === thread.title)
82
+ return;
83
+ db.prepare("UPDATE threads SET title = ?, title_source = 'generated', title_revision = ? WHERE thread_id = ?").run(title, request.revision, threadId);
84
+ appendThreadEvent(db, threadId, "thread.titled", { title, source: "generated" }, timestamp);
85
+ }
@@ -0,0 +1,47 @@
1
+ import type { Thread, ThreadEvent, ThreadMessage } from "@plico-ai/contracts";
2
+ import { type Metadata } from "../metadata.js";
3
+ import { type TitleRequest } from "./thread-title.js";
4
+ export interface ThreadStore {
5
+ readonly storageIdentity?: string;
6
+ /** Release resources owned by this store. */
7
+ close(): void;
8
+ createThread(metadata?: Metadata): Promise<Thread>;
9
+ /** `metadata` keeps threads whose top-level keys equal every given value. */
10
+ listThreads(filter?: {
11
+ metadata?: Metadata;
12
+ }): Promise<Thread[]>;
13
+ getThread(id: string): Promise<Thread | undefined>;
14
+ /** Names a thread for the user. Returns undefined for an unknown thread. */
15
+ renameThread(id: string, title: string): Promise<Thread | undefined>;
16
+ /** The transcript to title after a reply, or nothing when the thread no longer takes titles. */
17
+ getTitleRequest(id: string): Promise<TitleRequest | undefined>;
18
+ /**
19
+ * Stores a title written after a reply. A title the user chose is never replaced, and a title
20
+ * from an older `revision` than the stored one is dropped.
21
+ */
22
+ setGeneratedTitle(id: string, title: string, revision: number): Promise<boolean>;
23
+ getThreadForRun(runId: string): Promise<string | undefined>;
24
+ addMessage(threadId: string, content: string, runId?: string, recovery?: {
25
+ providerKey: string;
26
+ projectRoot: string;
27
+ context?: Metadata;
28
+ }): Promise<ThreadMessage | undefined>;
29
+ getMessages(threadId: string): Promise<ThreadMessage[]>;
30
+ getEvents(threadId: string): Promise<ThreadEvent[]>;
31
+ listQueuedRuns(): Promise<Array<{
32
+ runId: string;
33
+ threadId: string;
34
+ content: string;
35
+ providerKey: string | null;
36
+ projectRoot: string | null;
37
+ context?: Metadata;
38
+ history: Array<Pick<ThreadMessage, "role" | "content">>;
39
+ }>>;
40
+ }
41
+ export declare class ThreadBusyError extends Error {
42
+ constructor();
43
+ }
44
+ export declare function createSqliteThreadStore({ databasePath, readOnly, }: {
45
+ databasePath: string;
46
+ readOnly?: boolean;
47
+ }): ThreadStore;
@@ -0,0 +1,156 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { resolve } from "node:path";
3
+ import { matchesMetadata, parseMetadata } from "../metadata.js";
4
+ import { openDatabase } from "./open.js";
5
+ import { appendThreadEvent } from "./thread-log.js";
6
+ import { getTitleRequest, setGeneratedTitle, setUserTitle, } from "./thread-title.js";
7
+ const selectThread = "SELECT thread_id AS id, created_at AS createdAt, title, metadata_json AS metadataJson FROM threads";
8
+ function toThread(row) {
9
+ const { title, metadataJson, ...thread } = row;
10
+ const metadata = parseMetadata(metadataJson);
11
+ return {
12
+ ...thread,
13
+ ...(title === null ? {} : { title }),
14
+ ...(metadata === undefined ? {} : { metadata }),
15
+ };
16
+ }
17
+ export class ThreadBusyError extends Error {
18
+ constructor() {
19
+ super("Thread already has an active run.");
20
+ this.name = "ThreadBusyError";
21
+ }
22
+ }
23
+ export function createSqliteThreadStore({ databasePath, readOnly = false, }) {
24
+ const db = openDatabase(databasePath, readOnly);
25
+ return {
26
+ ...(databasePath === ":memory:"
27
+ ? {}
28
+ : {
29
+ storageIdentity: process.platform === "win32"
30
+ ? resolve(databasePath).toLowerCase()
31
+ : resolve(databasePath),
32
+ }),
33
+ close() {
34
+ db.close();
35
+ },
36
+ async createThread(metadata) {
37
+ const thread = {
38
+ id: randomUUID(),
39
+ createdAt: new Date().toISOString(),
40
+ ...(metadata === undefined ? {} : { metadata }),
41
+ };
42
+ transaction(db, () => {
43
+ appendThreadEvent(db, thread.id, "thread.created", { thread }, thread.createdAt);
44
+ db.prepare("INSERT INTO threads (thread_id, created_at, metadata_json) VALUES (?, ?, ?)").run(thread.id, thread.createdAt, metadata === undefined ? null : JSON.stringify(metadata));
45
+ });
46
+ return thread;
47
+ },
48
+ async listThreads(filter) {
49
+ return db
50
+ .prepare(`${selectThread} ORDER BY created_at, rowid`)
51
+ .all()
52
+ .map(toThread)
53
+ .filter((thread) => matchesMetadata(thread.metadata, filter?.metadata));
54
+ },
55
+ async getThread(id) {
56
+ const row = db.prepare(`${selectThread} WHERE thread_id = ?`).get(id);
57
+ return row === undefined ? undefined : toThread(row);
58
+ },
59
+ async renameThread(id, title) {
60
+ const found = transaction(db, () => {
61
+ if (!db.prepare("SELECT 1 FROM threads WHERE thread_id = ?").get(id))
62
+ return false;
63
+ setUserTitle(db, id, title, new Date().toISOString());
64
+ return true;
65
+ });
66
+ return found
67
+ ? toThread(db.prepare(`${selectThread} WHERE thread_id = ?`).get(id))
68
+ : undefined;
69
+ },
70
+ async getTitleRequest(id) {
71
+ return getTitleRequest(db, id);
72
+ },
73
+ async setGeneratedTitle(id, title, revision) {
74
+ return transaction(db, () => setGeneratedTitle(db, id, title, revision, new Date().toISOString()));
75
+ },
76
+ async getThreadForRun(runId) {
77
+ const row = db.prepare("SELECT thread_id FROM thread_runs WHERE run_id = ?").get(runId);
78
+ return row?.thread_id;
79
+ },
80
+ async addMessage(threadId, content, runId, recovery) {
81
+ return transaction(db, () => {
82
+ if (!db.prepare("SELECT 1 FROM threads WHERE thread_id = ?").get(threadId))
83
+ return undefined;
84
+ if (db
85
+ .prepare("SELECT 1 FROM thread_runs t LEFT JOIN runs r ON r.run_id = t.run_id WHERE t.thread_id = ? AND (r.status IS NULL OR r.status IN ('running', 'waiting')) LIMIT 1")
86
+ .get(threadId))
87
+ throw new ThreadBusyError();
88
+ if (runId !== undefined)
89
+ db.prepare("INSERT INTO thread_runs (run_id, thread_id, provider_key, project_root, context_json) VALUES (?, ?, ?, ?, ?)").run(runId, threadId, recovery?.providerKey ?? null, recovery?.projectRoot ?? null, recovery?.context === undefined ? null : JSON.stringify(recovery.context));
90
+ const message = {
91
+ id: randomUUID(),
92
+ threadId,
93
+ role: "user",
94
+ content,
95
+ createdAt: new Date().toISOString(),
96
+ ...(runId === undefined ? {} : { runId }),
97
+ };
98
+ const sequence = appendThreadEvent(db, threadId, "message.created", { message }, message.createdAt);
99
+ db.prepare("INSERT INTO thread_messages (message_id, thread_id, role, content, created_at, sequence, run_id) VALUES (?, ?, ?, ?, ?, ?, ?)").run(message.id, threadId, message.role, content, message.createdAt, sequence, runId ?? null);
100
+ return message;
101
+ });
102
+ },
103
+ async getMessages(threadId) {
104
+ return db
105
+ .prepare("SELECT message_id AS id, thread_id AS threadId, role, content, created_at AS createdAt, run_id AS runId FROM thread_messages WHERE thread_id = ? ORDER BY sequence")
106
+ .all(threadId)
107
+ .map(({ runId, ...message }) => ({
108
+ ...message,
109
+ ...(runId === null ? {} : { runId }),
110
+ }));
111
+ },
112
+ async listQueuedRuns() {
113
+ const queued = db
114
+ .prepare("SELECT t.run_id AS runId, t.thread_id AS threadId, t.provider_key AS providerKey, t.project_root AS projectRoot, t.context_json AS contextJson, m.content, m.sequence FROM thread_runs t JOIN thread_messages m ON m.run_id = t.run_id AND m.role = 'user' LEFT JOIN runs r ON r.run_id = t.run_id WHERE r.run_id IS NULL")
115
+ .all();
116
+ return queued.map(({ sequence, contextJson, ...run }) => ({
117
+ ...run,
118
+ ...(contextJson === null ? {} : { context: parseMetadata(contextJson) }),
119
+ history: db
120
+ .prepare("SELECT role, content FROM thread_messages WHERE thread_id = ? AND sequence < ? ORDER BY sequence")
121
+ .all(run.threadId, sequence),
122
+ }));
123
+ },
124
+ async getEvents(threadId) {
125
+ const rows = db
126
+ .prepare("SELECT sequence, event_id, schema_version, type, timestamp, payload_json FROM events WHERE stream_type = 'thread' AND stream_id = ? ORDER BY sequence")
127
+ .all(threadId);
128
+ return rows.map((row) => {
129
+ if (row.schema_version !== 1)
130
+ throw new Error(`Unsupported thread event schema version: ${row.schema_version}.`);
131
+ return {
132
+ schemaVersion: 1,
133
+ streamType: "thread",
134
+ streamId: threadId,
135
+ sequence: row.sequence,
136
+ id: row.event_id,
137
+ type: row.type,
138
+ timestamp: row.timestamp,
139
+ payload: JSON.parse(row.payload_json),
140
+ };
141
+ });
142
+ },
143
+ };
144
+ }
145
+ function transaction(db, fn) {
146
+ db.exec("BEGIN IMMEDIATE");
147
+ try {
148
+ const result = fn();
149
+ db.exec("COMMIT");
150
+ return result;
151
+ }
152
+ catch (error) {
153
+ db.exec("ROLLBACK");
154
+ throw error;
155
+ }
156
+ }
@@ -0,0 +1 @@
1
+ export declare function isStopword(word: string): boolean;
@@ -0,0 +1,16 @@
1
+ // Words that never name a topic: function words, greetings and generic verbs. English is
2
+ // complete enough for titles; Spanish, German and Japanese carry only the most common words.
3
+ const english = `a an the and or but if then so of to in on at by for with from into onto about as is are was were be been being
4
+ am do does did done have has had having i me my mine we us our ours you your yours he him his she her they them their it its
5
+ this that these those there here what which who whom whose when where why how all any some no not only own same too very
6
+ can could would should will shall may might must just also again ok okay yes yeah please thanks thank hi hello hey
7
+ good morning evening afternoon need want like help get got make let lets something anything everything one able sure cannot
8
+ today yesterday tomorrow now still else last next talk tell give show find check see look know think try use getting going go
9
+ that's it's i'm i'd i've don't doesn't didn't can't won't what's`;
10
+ const spanish = "el la los las un una de del al y o en por para con que es necesito nuestra nuestro mi tu su hola puedo cuál cual";
11
+ const german = "der die das den dem des ein eine einen und oder im in über ueber von zu mit für bitte erstelle guten morgen hallo";
12
+ const japanese = "の を に は が で て と も ください しました します です ます こんにちは した";
13
+ const stopwords = new Set([english, spanish, german, japanese].flatMap((list) => list.split(/\s+/)));
14
+ export function isStopword(word) {
15
+ return stopwords.has(word.toLowerCase()) || /^\d+([.,]\d+)?$/.test(word);
16
+ }
@@ -0,0 +1,9 @@
1
+ export interface TitleMessage {
2
+ role: "user" | "assistant";
3
+ content: string;
4
+ }
5
+ /**
6
+ * Names a conversation from what the user said, with the assistant only confirming a topic.
7
+ * Returns undefined while the user has not said enough to name it, such as after "Hi".
8
+ */
9
+ export declare function suggestThreadTitle(messages: TitleMessage[]): string | undefined;
@@ -0,0 +1,102 @@
1
+ import { isStopword } from "./stopwords.js";
2
+ import { clip, tokenize, weighSentences } from "./words.js";
3
+ const maxPhraseWords = 3;
4
+ const japanese = /[぀-ヿ一-鿿]/;
5
+ const key = (word) => word.toLowerCase();
6
+ function collect(messages, role) {
7
+ const frequency = new Map();
8
+ const firstSeen = new Map();
9
+ let position = 0;
10
+ for (const message of messages) {
11
+ if (message.role !== role)
12
+ continue;
13
+ const sentences = role === "user"
14
+ ? weighSentences(message.content)
15
+ : [{ text: clip(message.content), weight: 1 }];
16
+ for (const sentence of sentences) {
17
+ for (const word of tokenize(sentence.text)) {
18
+ if (word === null || isStopword(word))
19
+ continue;
20
+ frequency.set(key(word), (frequency.get(key(word)) ?? 0) + sentence.weight);
21
+ if (!firstSeen.has(key(word)))
22
+ firstSeen.set(key(word), position);
23
+ position++;
24
+ }
25
+ }
26
+ }
27
+ // Position only means something in short text; in a long paste the ask decides.
28
+ if (position > 60)
29
+ for (const word of firstSeen.keys())
30
+ firstSeen.set(word, 0);
31
+ return { frequency, firstSeen };
32
+ }
33
+ /** Runs of consecutive non-stopwords, cut into windows of up to three words (as in RAKE). */
34
+ function candidatePhrases(messages) {
35
+ const phrases = [];
36
+ for (const message of messages) {
37
+ let run = [];
38
+ const flush = () => {
39
+ for (let size = Math.min(maxPhraseWords, run.length); size >= 1; size--)
40
+ for (let start = 0; start + size <= run.length; start++)
41
+ phrases.push(run.slice(start, start + size));
42
+ run = [];
43
+ };
44
+ for (const word of tokenize(clip(message.content))) {
45
+ if (word === null || isStopword(word))
46
+ flush();
47
+ else
48
+ run.push(word);
49
+ }
50
+ flush();
51
+ }
52
+ return phrases;
53
+ }
54
+ /**
55
+ * Names a conversation from what the user said, with the assistant only confirming a topic.
56
+ * Returns undefined while the user has not said enough to name it, such as after "Hi".
57
+ */
58
+ export function suggestThreadTitle(messages) {
59
+ const user = collect(messages, "user");
60
+ if (user.frequency.size < 2)
61
+ return undefined;
62
+ const assistant = collect(messages, "assistant");
63
+ const assistantText = messages
64
+ .filter((message) => message.role === "assistant")
65
+ .map((message) => clip(message.content).toLowerCase())
66
+ .join(" ");
67
+ const wordScore = (word) => {
68
+ const frequency = user.frequency.get(key(word)) ?? 0;
69
+ if (frequency === 0)
70
+ return 0;
71
+ const early = 1 / Math.log2(2 + (user.firstSeen.get(key(word)) ?? 0) / 4);
72
+ return (frequency + 0.5 * (assistant.frequency.get(key(word)) ?? 0)) * (0.5 + early);
73
+ };
74
+ const scored = new Map();
75
+ for (const phrase of candidatePhrases(messages)) {
76
+ const id = phrase.map(key).join(" ");
77
+ if (scored.has(id) || !phrase.every((word) => user.frequency.has(key(word))))
78
+ continue;
79
+ let score = phrase.reduce((sum, word) => sum + wordScore(word), 0);
80
+ // The assistant restating the user's phrase is a strong topic signal.
81
+ if (phrase.length > 1 && assistantText.includes(id))
82
+ score *= 1.5;
83
+ // A very short single word is a weak title.
84
+ if (phrase.length === 1 && (phrase[0]?.length ?? 0) < 4)
85
+ score *= 0.5;
86
+ scored.set(id, { words: phrase, score });
87
+ }
88
+ const ranked = [...scored.values()].sort((a, b) => b.score - a.score);
89
+ const top = ranked[0];
90
+ if (!top)
91
+ return undefined;
92
+ const used = new Set(top.words.map(key));
93
+ const second = ranked.find((item) => item !== top &&
94
+ item.score >= 0.45 * top.score &&
95
+ !item.words.some((word) => used.has(key(word))));
96
+ const joiner = japanese.test(top.words.join("")) ? "" : " ";
97
+ const title = [top, second]
98
+ .filter((item) => item !== undefined)
99
+ .map((item) => item.words.join(joiner))
100
+ .join(" · ");
101
+ return title.charAt(0).toUpperCase() + title.slice(1);
102
+ }
@@ -0,0 +1,10 @@
1
+ import type { ProjectConfig } from "@plico-ai/contracts";
2
+ import type { ModelProvider } from "../types/index.js";
3
+ import type { TitleMessage } from "./suggest-title.js";
4
+ /** Names a thread from its transcript. May return nothing when there is nothing to say yet. */
5
+ export type ThreadTitler = (messages: TitleMessage[]) => Promise<string | undefined>;
6
+ /**
7
+ * The titler a project asks for: its `threads/title.ts` function, else the model when
8
+ * `threads.title` is `model`. Without either, threads keep their keyword titles.
9
+ */
10
+ export declare function createThreadTitler(root: string, config: ProjectConfig, provider: ModelProvider | undefined): Promise<ThreadTitler | undefined>;
@@ -0,0 +1,63 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { findProjectThreadTitleFile } from "@plico-ai/core";
3
+ import { register } from "tsx/esm/api";
4
+ const maxTitleLength = 80;
5
+ const quotes = /^["'`‘’“”]+|["'`‘’“”.]+$/g;
6
+ let registered = false;
7
+ /** Takes the first line, drops quotes and trailing punctuation, and keeps it short. */
8
+ function cleanTitle(text) {
9
+ const line = text.trim().split(/\r?\n/)[0] ?? "";
10
+ const title = line.trim().replace(quotes, "").trim();
11
+ return title === "" ? undefined : title.slice(0, maxTitleLength);
12
+ }
13
+ function modelTitler(provider) {
14
+ return async (messages) => {
15
+ const transcript = messages
16
+ .map((message) => `${message.role === "user" ? "User" : "Assistant"}: ${message.content}`)
17
+ .join("\n")
18
+ .slice(0, 4000);
19
+ let text = "";
20
+ for await (const chunk of provider.stream({
21
+ messages: [
22
+ {
23
+ role: "system",
24
+ content: "You name chat threads. Reply with the title only, no quotes.",
25
+ },
26
+ {
27
+ role: "user",
28
+ content: `Write a title of at most six words for this conversation:\n\n${transcript}`,
29
+ },
30
+ ],
31
+ maxSteps: 1,
32
+ }, { signal: AbortSignal.timeout(30_000) }))
33
+ if (chunk.type === "text")
34
+ text += chunk.text;
35
+ return cleanTitle(text);
36
+ };
37
+ }
38
+ async function moduleTitler(file) {
39
+ if (!registered) {
40
+ register();
41
+ registered = true;
42
+ }
43
+ const loaded = (await import(pathToFileURL(file).href));
44
+ const title = loaded.default;
45
+ if (typeof title !== "function")
46
+ throw new Error("threads/title must export a function by default.");
47
+ return async (messages) => {
48
+ const result = await title(messages);
49
+ return typeof result === "string" ? cleanTitle(result) : undefined;
50
+ };
51
+ }
52
+ /**
53
+ * The titler a project asks for: its `threads/title.ts` function, else the model when
54
+ * `threads.title` is `model`. Without either, threads keep their keyword titles.
55
+ */
56
+ export async function createThreadTitler(root, config, provider) {
57
+ const file = await findProjectThreadTitleFile(root);
58
+ if (file)
59
+ return moduleTitler(file);
60
+ if (config.threads?.title === "model" && provider)
61
+ return modelTitler(provider);
62
+ return undefined;
63
+ }
@@ -0,0 +1,12 @@
1
+ /** Words in order, with `null` marking a phrase break such as punctuation. */
2
+ export declare function tokenize(text: string): Array<string | null>;
3
+ /** Long pastes are mostly context, so keep the start and the end, where the ask usually is. */
4
+ export declare function clip(text: string): string;
5
+ /**
6
+ * Splits a message into weighted sentences. In a long message the sentence that asks for
7
+ * something counts most; a short message is all ask, so it stays whole.
8
+ */
9
+ export declare function weighSentences(text: string): Array<{
10
+ text: string;
11
+ weight: number;
12
+ }>;
@@ -0,0 +1,39 @@
1
+ const wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" });
2
+ const sentenceSegmenter = new Intl.Segmenter(undefined, { granularity: "sentence" });
3
+ const asks = /[??]\s*$|\b(please|can you|could you|would you|bitte|por favor)\b|ください/i;
4
+ /** Words in order, with `null` marking a phrase break such as punctuation. */
5
+ export function tokenize(text) {
6
+ const out = [];
7
+ let joinNext = false;
8
+ for (const part of wordSegmenter.segment(text)) {
9
+ if (!part.isWordLike) {
10
+ if (part.segment === "-" && typeof out.at(-1) === "string")
11
+ joinNext = true;
12
+ else if (/\S/.test(part.segment) && !/^['’]$/.test(part.segment))
13
+ out.push(null);
14
+ continue;
15
+ }
16
+ const word = part.segment.replace(/['’]s$/i, "");
17
+ if (joinNext)
18
+ out[out.length - 1] += `-${word}`;
19
+ else
20
+ out.push(word);
21
+ joinNext = false;
22
+ }
23
+ return out;
24
+ }
25
+ /** Long pastes are mostly context, so keep the start and the end, where the ask usually is. */
26
+ export function clip(text) {
27
+ return text.length <= 4000 ? text : `${text.slice(0, 3000)}\n${text.slice(-1000)}`;
28
+ }
29
+ /**
30
+ * Splits a message into weighted sentences. In a long message the sentence that asks for
31
+ * something counts most; a short message is all ask, so it stays whole.
32
+ */
33
+ export function weighSentences(text) {
34
+ const parts = [...sentenceSegmenter.segment(clip(text))].map((part) => part.segment);
35
+ const isAsk = parts.map((part) => asks.test(part.trim()));
36
+ if (text.split(/\s+/).length <= 60 || !isAsk.includes(true))
37
+ return [{ text: parts.join(""), weight: 1 }];
38
+ return parts.map((part, index) => ({ text: part, weight: isAsk[index] ? 3 : 0.5 }));
39
+ }
@@ -0,0 +1,29 @@
1
+ import type { ArtifactFileMetadata } from "@plico-ai/contracts";
2
+ import type { ToolArtifactContext } from "../types/index.js";
3
+ type ListedArtifact = Pick<ArtifactFileMetadata, "path" | "sizeBytes" | "mimeType" | "version">;
4
+ export declare function listFiles(input: unknown, artifacts: ToolArtifactContext): Promise<{
5
+ files: ListedArtifact[];
6
+ offset: number;
7
+ nextOffset?: number;
8
+ }>;
9
+ export declare function readFile(input: unknown, artifacts: ToolArtifactContext): Promise<{
10
+ path: string;
11
+ content: string;
12
+ mimeType: string;
13
+ version: number;
14
+ offset: number;
15
+ nextOffset?: number;
16
+ } | ({
17
+ path: string;
18
+ binary: true;
19
+ } & ArtifactFileMetadata)>;
20
+ export declare function writeFile(input: unknown, artifacts: ToolArtifactContext): Promise<{
21
+ path: string;
22
+ version: number;
23
+ }>;
24
+ export declare function editFile(input: unknown, artifacts: ToolArtifactContext): Promise<{
25
+ path: string;
26
+ version: number;
27
+ replacementCount: number;
28
+ }>;
29
+ export {};