@plico-ai/runtime 0.0.0-stage → 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 (188) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +11 -2
  4. package/dist/artifacts.d.ts +10 -0
  5. package/dist/artifacts.js +106 -0
  6. package/dist/engine/context.d.ts +16 -0
  7. package/dist/engine/context.js +77 -0
  8. package/dist/engine/engine.d.ts +12 -0
  9. package/dist/engine/engine.js +235 -0
  10. package/dist/engine/errors.d.ts +12 -0
  11. package/dist/engine/errors.js +19 -0
  12. package/dist/engine/index.d.ts +7 -0
  13. package/dist/engine/index.js +5 -0
  14. package/dist/engine/journal.d.ts +65 -0
  15. package/dist/engine/journal.js +1 -0
  16. package/dist/engine/memory-journal.d.ts +2 -0
  17. package/dist/engine/memory-journal.js +125 -0
  18. package/dist/engine/sqlite-engine.d.ts +7 -0
  19. package/dist/engine/sqlite-engine.js +6 -0
  20. package/dist/engine/sqlite-journal.d.ts +5 -0
  21. package/dist/engine/sqlite-journal.js +136 -0
  22. package/dist/engine/types.d.ts +97 -0
  23. package/dist/engine/types.js +1 -0
  24. package/dist/env.d.ts +7 -0
  25. package/dist/env.js +19 -0
  26. package/dist/errors.d.ts +18 -0
  27. package/dist/errors.js +40 -0
  28. package/dist/evals/discover.d.ts +3 -0
  29. package/dist/evals/discover.js +49 -0
  30. package/dist/evals/index.d.ts +3 -0
  31. package/dist/evals/index.js +3 -0
  32. package/dist/evals/promote.d.ts +7 -0
  33. package/dist/evals/promote.js +32 -0
  34. package/dist/evals/run-evals.d.ts +2 -0
  35. package/dist/evals/run-evals.js +69 -0
  36. package/dist/events.d.ts +28 -0
  37. package/dist/events.js +104 -0
  38. package/dist/guards.d.ts +1 -0
  39. package/dist/guards.js +3 -0
  40. package/dist/index.d.ts +18 -0
  41. package/dist/index.js +17 -0
  42. package/dist/instructions.d.ts +4 -0
  43. package/dist/instructions.js +23 -0
  44. package/dist/policy/approval.d.ts +8 -0
  45. package/dist/policy/approval.js +27 -0
  46. package/dist/providers/ai-sdk.d.ts +10 -0
  47. package/dist/providers/ai-sdk.js +154 -0
  48. package/dist/providers/configured.d.ts +17 -0
  49. package/dist/providers/configured.js +60 -0
  50. package/dist/providers/openai-compatible.d.ts +8 -0
  51. package/dist/providers/openai-compatible.js +14 -0
  52. package/dist/providers/project-module.d.ts +7 -0
  53. package/dist/providers/project-module.js +29 -0
  54. package/dist/providers/registry.d.ts +20 -0
  55. package/dist/providers/registry.js +53 -0
  56. package/dist/readiness/check-readiness.d.ts +2 -0
  57. package/dist/readiness/check-readiness.js +33 -0
  58. package/dist/readiness/checks/approval-policy.d.ts +5 -0
  59. package/dist/readiness/checks/approval-policy.js +19 -0
  60. package/dist/readiness/checks/artifact-access.d.ts +3 -0
  61. package/dist/readiness/checks/artifact-access.js +5 -0
  62. package/dist/readiness/checks/artifacts.d.ts +6 -0
  63. package/dist/readiness/checks/artifacts.js +22 -0
  64. package/dist/readiness/checks/env.d.ts +3 -0
  65. package/dist/readiness/checks/env.js +10 -0
  66. package/dist/readiness/checks/evals.d.ts +2 -0
  67. package/dist/readiness/checks/evals.js +53 -0
  68. package/dist/readiness/checks/persistence.d.ts +3 -0
  69. package/dist/readiness/checks/persistence.js +61 -0
  70. package/dist/readiness/checks/project.d.ts +7 -0
  71. package/dist/readiness/checks/project.js +21 -0
  72. package/dist/readiness/checks/provider.d.ts +2 -0
  73. package/dist/readiness/checks/provider.js +20 -0
  74. package/dist/readiness/checks/server-config.d.ts +2 -0
  75. package/dist/readiness/checks/server-config.js +13 -0
  76. package/dist/readiness/checks/studio.d.ts +2 -0
  77. package/dist/readiness/checks/studio.js +16 -0
  78. package/dist/readiness/checks/thread-store.d.ts +3 -0
  79. package/dist/readiness/checks/thread-store.js +12 -0
  80. package/dist/readiness/checks/tools.d.ts +3 -0
  81. package/dist/readiness/checks/tools.js +42 -0
  82. package/dist/readiness/diagnostic.d.ts +2 -0
  83. package/dist/readiness/diagnostic.js +7 -0
  84. package/dist/readiness/index.d.ts +1 -0
  85. package/dist/readiness/index.js +1 -0
  86. package/dist/readiness/open-database.d.ts +8 -0
  87. package/dist/readiness/open-database.js +15 -0
  88. package/dist/run/artifact-tool-context.d.ts +19 -0
  89. package/dist/run/artifact-tool-context.js +74 -0
  90. package/dist/run/finalize.d.ts +16 -0
  91. package/dist/run/finalize.js +11 -0
  92. package/dist/run/index.d.ts +1 -0
  93. package/dist/run/index.js +1 -0
  94. package/dist/run/pinned-instructions.d.ts +18 -0
  95. package/dist/run/pinned-instructions.js +36 -0
  96. package/dist/run/record-tools.d.ts +8 -0
  97. package/dist/run/record-tools.js +21 -0
  98. package/dist/run/run-project.d.ts +17 -0
  99. package/dist/run/run-project.js +222 -0
  100. package/dist/run/strategies/approval.d.ts +9 -0
  101. package/dist/run/strategies/approval.js +42 -0
  102. package/dist/run/strategies/default-dry.d.ts +3 -0
  103. package/dist/run/strategies/default-dry.js +5 -0
  104. package/dist/run/strategies/provider.d.ts +6 -0
  105. package/dist/run/strategies/provider.js +193 -0
  106. package/dist/run/strategies/scripted.d.ts +5 -0
  107. package/dist/run/strategies/scripted.js +106 -0
  108. package/dist/run/tool-fingerprint.d.ts +2 -0
  109. package/dist/run/tool-fingerprint.js +26 -0
  110. package/dist/run/tool-step.d.ts +4 -0
  111. package/dist/run/tool-step.js +62 -0
  112. package/dist/run/tracing.d.ts +12 -0
  113. package/dist/run/tracing.js +92 -0
  114. package/dist/script.d.ts +4 -0
  115. package/dist/script.js +51 -0
  116. package/dist/storage/index.d.ts +1 -0
  117. package/dist/storage/index.js +1 -0
  118. package/dist/storage/metadata.d.ts +5 -0
  119. package/dist/storage/metadata.js +26 -0
  120. package/dist/storage/sqlite/append-event.d.ts +3 -0
  121. package/dist/storage/sqlite/append-event.js +108 -0
  122. package/dist/storage/sqlite/driver.d.ts +9 -0
  123. package/dist/storage/sqlite/driver.js +13 -0
  124. package/dist/storage/sqlite/index.d.ts +1 -0
  125. package/dist/storage/sqlite/index.js +1 -0
  126. package/dist/storage/sqlite/open.d.ts +3 -0
  127. package/dist/storage/sqlite/open.js +54 -0
  128. package/dist/storage/sqlite/owner-lock.d.ts +5 -0
  129. package/dist/storage/sqlite/owner-lock.js +55 -0
  130. package/dist/storage/sqlite/project-thread.d.ts +4 -0
  131. package/dist/storage/sqlite/project-thread.js +33 -0
  132. package/dist/storage/sqlite/queries.d.ts +9 -0
  133. package/dist/storage/sqlite/queries.js +134 -0
  134. package/dist/storage/sqlite/recover-interrupted.d.ts +2 -0
  135. package/dist/storage/sqlite/recover-interrupted.js +46 -0
  136. package/dist/storage/sqlite/schema.d.ts +10 -0
  137. package/dist/storage/sqlite/schema.js +205 -0
  138. package/dist/storage/sqlite/store.d.ts +2 -0
  139. package/dist/storage/sqlite/store.js +51 -0
  140. package/dist/storage/sqlite/thread-log.d.ts +4 -0
  141. package/dist/storage/sqlite/thread-log.js +9 -0
  142. package/dist/storage/sqlite/thread-title.d.ts +24 -0
  143. package/dist/storage/sqlite/thread-title.js +85 -0
  144. package/dist/storage/sqlite/threads.d.ts +47 -0
  145. package/dist/storage/sqlite/threads.js +156 -0
  146. package/dist/threads/stopwords.d.ts +1 -0
  147. package/dist/threads/stopwords.js +16 -0
  148. package/dist/threads/suggest-title.d.ts +9 -0
  149. package/dist/threads/suggest-title.js +102 -0
  150. package/dist/threads/titler.d.ts +10 -0
  151. package/dist/threads/titler.js +63 -0
  152. package/dist/threads/words.d.ts +12 -0
  153. package/dist/threads/words.js +39 -0
  154. package/dist/tools/artifact-model-tool-operations.d.ts +29 -0
  155. package/dist/tools/artifact-model-tool-operations.js +124 -0
  156. package/dist/tools/artifact-model-tools.d.ts +2 -0
  157. package/dist/tools/artifact-model-tools.js +101 -0
  158. package/dist/tools/discover.d.ts +4 -0
  159. package/dist/tools/discover.js +34 -0
  160. package/dist/tools/index.d.ts +3 -0
  161. package/dist/tools/index.js +3 -0
  162. package/dist/tools/load.d.ts +2 -0
  163. package/dist/tools/load.js +83 -0
  164. package/dist/tools/normalize.d.ts +2 -0
  165. package/dist/tools/normalize.js +48 -0
  166. package/dist/tools/schema.d.ts +7 -0
  167. package/dist/tools/schema.js +101 -0
  168. package/dist/tools/skill-model-tool.d.ts +7 -0
  169. package/dist/tools/skill-model-tool.js +38 -0
  170. package/dist/types/artifacts.d.ts +5 -0
  171. package/dist/types/artifacts.js +1 -0
  172. package/dist/types/evals.d.ts +16 -0
  173. package/dist/types/evals.js +1 -0
  174. package/dist/types/index.d.ts +10 -0
  175. package/dist/types/index.js +1 -0
  176. package/dist/types/provider.d.ts +45 -0
  177. package/dist/types/provider.js +1 -0
  178. package/dist/types/readiness.d.ts +9 -0
  179. package/dist/types/readiness.js +1 -0
  180. package/dist/types/run.d.ts +28 -0
  181. package/dist/types/run.js +1 -0
  182. package/dist/types/script.d.ts +16 -0
  183. package/dist/types/script.js +1 -0
  184. package/dist/types/store.d.ts +24 -0
  185. package/dist/types/store.js +1 -0
  186. package/dist/types/tools.d.ts +15 -0
  187. package/dist/types/tools.js +1 -0
  188. package/package.json +77 -3
@@ -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 {};
@@ -0,0 +1,124 @@
1
+ import mime from "mime";
2
+ import picomatch from "picomatch";
3
+ import { validateArtifactPath } from "../run/artifact-tool-context.js";
4
+ const defaultListLimit = 100;
5
+ const defaultReadLimit = 200;
6
+ const maxReadBytes = 32 * 1024;
7
+ export async function listFiles(input, artifacts) {
8
+ const value = input;
9
+ const offset = value.offset ?? 0;
10
+ const limit = value.limit ?? defaultListLimit;
11
+ let matches = await artifacts.list();
12
+ if (value.glob !== undefined) {
13
+ let match;
14
+ try {
15
+ match = picomatch(value.glob, { strictBrackets: true, maxLength: 1024, dot: true });
16
+ }
17
+ catch (error) {
18
+ throw new Error(`Invalid file glob: ${error instanceof Error ? error.message : String(error)}`);
19
+ }
20
+ matches = matches.filter((file) => match(file.path));
21
+ }
22
+ matches.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
23
+ const files = matches
24
+ .slice(offset, offset + limit)
25
+ .map(({ path, sizeBytes, mimeType, version }) => ({
26
+ path,
27
+ sizeBytes,
28
+ mimeType,
29
+ version,
30
+ }));
31
+ const nextOffset = offset + files.length;
32
+ return {
33
+ files,
34
+ offset,
35
+ ...(nextOffset < matches.length ? { nextOffset } : {}),
36
+ };
37
+ }
38
+ export async function readFile(input, artifacts) {
39
+ const value = input;
40
+ validateArtifactPath(value.path);
41
+ const file = await artifacts.read(value.path);
42
+ if (!file)
43
+ throw new Error(`Artifact not found: ${value.path}`);
44
+ if (file.content instanceof Uint8Array)
45
+ return { ...metadata(file), path: file.path, binary: true };
46
+ const lines = file.content.match(/[^\n]*\n|[^\n]+$/g) ?? [];
47
+ const offset = value.offset ?? 1;
48
+ const limit = value.limit ?? defaultReadLimit;
49
+ const start = offset - 1;
50
+ const page = [];
51
+ let pageBytes = 0;
52
+ for (let index = start; index < Math.min(lines.length, start + limit); index++) {
53
+ const line = lines[index] ?? "";
54
+ const lineBytes = Buffer.byteLength(line, "utf8");
55
+ if (pageBytes + lineBytes > maxReadBytes) {
56
+ if (page.length === 0)
57
+ throw new Error(`Line ${index + 1} of ${value.path} exceeds the 32 KiB read limit.`);
58
+ break;
59
+ }
60
+ page.push(line);
61
+ pageBytes += lineBytes;
62
+ }
63
+ const nextOffset = start + page.length + 1;
64
+ return {
65
+ path: file.path,
66
+ content: page.join(""),
67
+ mimeType: file.mimeType,
68
+ version: file.version,
69
+ offset,
70
+ ...(nextOffset <= lines.length ? { nextOffset } : {}),
71
+ };
72
+ }
73
+ export async function writeFile(input, artifacts) {
74
+ const value = input;
75
+ const mimeType = value.mimeType ?? mime.getType(value.path) ?? "text/plain";
76
+ return artifacts.write(value.path, value.content, { mimeType });
77
+ }
78
+ export async function editFile(input, artifacts) {
79
+ const value = input;
80
+ if (value.old === value.new)
81
+ throw new Error("The old and new text are identical; no edit was made.");
82
+ validateArtifactPath(value.path);
83
+ const file = await artifacts.read(value.path);
84
+ if (!file)
85
+ throw new Error(`Artifact not found: ${value.path}`);
86
+ if (file.content instanceof Uint8Array)
87
+ throw new Error(`Cannot edit binary artifact ${value.path}; edit_file accepts text only.`);
88
+ const occurrences = countMatches(file.content, value.old);
89
+ if (occurrences === 0)
90
+ throw new Error(`Text to replace was not found in ${value.path}.`);
91
+ if (occurrences > 1 && value.replaceAll !== true)
92
+ throw new Error(`Text to replace occurs ${occurrences} times in ${value.path}; set replaceAll to true.`);
93
+ const content = value.replaceAll
94
+ ? file.content.replaceAll(value.old, () => value.new)
95
+ : replaceFirst(file.content, value.old, value.new);
96
+ const write = await artifacts.write(value.path, content, { mimeType: file.mimeType });
97
+ return { ...write, replacementCount: value.replaceAll ? occurrences : 1 };
98
+ }
99
+ function metadata(file) {
100
+ return {
101
+ path: file.path,
102
+ sizeBytes: file.content instanceof Uint8Array
103
+ ? file.content.byteLength
104
+ : Buffer.byteLength(file.content, "utf8"),
105
+ mimeType: file.mimeType,
106
+ version: file.version,
107
+ contentHash: file.contentHash,
108
+ };
109
+ }
110
+ function countMatches(content, search) {
111
+ let count = 0;
112
+ let offset = 0;
113
+ while (true) {
114
+ const index = content.indexOf(search, offset);
115
+ if (index < 0)
116
+ return count;
117
+ count++;
118
+ offset = index + search.length;
119
+ }
120
+ }
121
+ function replaceFirst(content, search, replacement) {
122
+ const index = content.indexOf(search);
123
+ return `${content.slice(0, index)}${replacement}${content.slice(index + search.length)}`;
124
+ }
@@ -0,0 +1,2 @@
1
+ import type { DeclaredTool } from "../types/index.js";
2
+ export declare function createArtifactModelTools(): Promise<DeclaredTool[]>;