@openturtle/cli 0.3.3 → 0.3.4

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.
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
4
5
  import { readJsonFile } from '../core/json.js';
5
6
  import { claudeLegacyServerPath, claudeSettingsPath, codexConfigPath, cursorLegacyServerPath, kiroLegacyServerPath, qoderLegacyServerPath, removeLegacyCodexConfig, removeLegacyJsonServer, } from '../core/legacy-agent-cleanup.js';
6
7
  import { ensureFileDir } from '../core/paths.js';
@@ -123,40 +124,7 @@ function writeSkill(filePath) {
123
124
  writeTextFile(filePath, skillContent());
124
125
  }
125
126
  export function skillContent() {
126
- return [
127
- '---',
128
- 'name: openturtle-cli',
129
- 'description: Use OpenTurtle projects, team roster, project context, knowledge, meetings, goals, todos, and worklogs from an agent or terminal without requiring the Desktop client.',
130
- '---',
131
- '',
132
- '# OpenTurtle CLI',
133
- '',
134
- 'Use the direct `ot` command for every OpenTurtle operation. Do not use an OpenTurtle MCP server and do not install OpenTurtle hooks.',
135
- 'Start with `command -v ot`. Run `ot --json doctor` before a remote workflow when authentication or server reachability is uncertain.',
136
- 'If authentication is missing, ask the user to run `ot auth login --web`; never request or expose their browser token.',
137
- 'Run commands as `ot ... --json` so results are stable and machine-readable.',
138
- 'Before a write, run the same command with `--dry-run` and inspect `request.path` and `request.body`.',
139
- 'Treat knowledge as reusable memory objects. Treat source files as original materials referenced by knowledge, not as a second knowledge system.',
140
- '',
141
- '## Meeting workflow',
142
- '',
143
- '1. Resolve the project with `ot projects resolve`, then inspect `ot roster list` and `ot context get`.',
144
- '2. Upload audio with `ot meetings upload` or inspect an existing meeting with `ot meetings show`, `transcript`, and `minutes`.',
145
- '3. Publish complete minutes with `ot meetings publish <meeting-id>`. Meeting minutes belong in Knowledge, not WorkLog.',
146
- '4. Review drafts using `ot meetings updates list <meeting-id>` and edit them when needed.',
147
- '5. Confirm only reviewed updates with `ot meetings updates confirm ... --knowledge <knowledge-id>`. Never confirm without the saved meeting knowledge id.',
148
- '6. Use the materialized Todo or WorkLog ids returned by confirmation as the project update evidence.',
149
- '',
150
- '## Todo quality',
151
- '',
152
- 'When creating or proposing OpenTurtle todos, include source context, scope/environment, concrete action steps, measurable acceptance criteria, and required evidence.',
153
- 'Source context means the goal, meeting, plan artifact, commit, PR, file, worklog, or user request that caused the todo.',
154
- 'If the source context, environment, owner, due date, measurable acceptance criteria, or evidence is unclear, ask one concise question before creating the todo.',
155
- 'Avoid vague acceptance such as "no obvious delay", "works normally", or "verify it". Prefer concrete thresholds, environments, and evidence.',
156
- 'Use `--body-file <json>` or `--body-file -` for structured writes so shell quoting does not corrupt payloads.',
157
- 'Do not write to `.openturtle/`; CLI state lives in `.openturtle-cli/` and `~/.openturtle/cli/`.',
158
- '',
159
- ].join('\n');
127
+ return fs.readFileSync(fileURLToPath(new URL('../../skill/openturtle-cli/SKILL.md', import.meta.url)), 'utf8');
160
128
  }
161
129
  function appendAgentsSnippet(workspace) {
162
130
  const filePath = path.join(workspace, 'AGENTS.md');
@@ -0,0 +1,398 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { localKnowledgeStoreRoot } from './root.js';
5
+ const MAX_INDEXED_FILE_BYTES = 20 * 1024 * 1024;
6
+ const MAX_CHUNK_CHARACTERS = 6_000;
7
+ function indexPath(userDataPath = localKnowledgeStoreRoot()) {
8
+ return path.join(userDataPath, 'knowledge', 'index.db');
9
+ }
10
+ async function openIndex(userDataPath) {
11
+ const filePath = indexPath(userDataPath);
12
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
13
+ const importBuiltin = new Function('specifier', 'return import(specifier)');
14
+ const sqlite = await importBuiltin('node:sqlite');
15
+ if (!sqlite.DatabaseSync)
16
+ throw new Error('当前 Node.js 运行时不支持本地知识索引');
17
+ const db = new sqlite.DatabaseSync(filePath);
18
+ db.exec(`
19
+ PRAGMA busy_timeout = 5000;
20
+ PRAGMA journal_mode = WAL;
21
+ PRAGMA synchronous = NORMAL;
22
+ CREATE TABLE IF NOT EXISTS indexed_assets (
23
+ asset_id TEXT PRIMARY KEY,
24
+ content_hash TEXT NOT NULL,
25
+ index_status TEXT NOT NULL,
26
+ indexed_at TEXT NOT NULL
27
+ );
28
+ CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_chunks_fts USING fts5(
29
+ asset_id UNINDEXED,
30
+ chunk_index UNINDEXED,
31
+ line_start UNINDEXED,
32
+ line_end UNINDEXED,
33
+ content,
34
+ tokenize='trigram'
35
+ );
36
+ `);
37
+ return db;
38
+ }
39
+ function isIndexableText(asset) {
40
+ return (asset.mimeType.startsWith('text/') ||
41
+ /\.(?:md|markdown|txt|log|csv|jsonl?|ya?ml|xml|html?|tsx?|jsx?|py|sh|sql|toml|ini|conf)$/i.test(asset.name));
42
+ }
43
+ function expectedIndexStatus(asset) {
44
+ if (asset.syncState === 'missing')
45
+ return 'missing';
46
+ if (!isIndexableText(asset) || asset.sizeBytes > MAX_INDEXED_FILE_BYTES)
47
+ return 'unsupported';
48
+ return 'indexed';
49
+ }
50
+ function indexFingerprint(asset) {
51
+ return crypto
52
+ .createHash('sha256')
53
+ .update(`${asset.contentHash}\n${asset.entry?.title || ''}\n${asset.entry?.kind || ''}\n${asset.entry?.projectId || ''}`)
54
+ .digest('hex');
55
+ }
56
+ function textChunks(content) {
57
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
58
+ const chunks = [];
59
+ let start = 0;
60
+ while (start < lines.length) {
61
+ let end = start;
62
+ let size = 0;
63
+ while (end < lines.length) {
64
+ const nextSize = lines[end].length + 1;
65
+ if (end > start && size + nextSize > MAX_CHUNK_CHARACTERS)
66
+ break;
67
+ size += nextSize;
68
+ end += 1;
69
+ }
70
+ const chunk = lines.slice(start, end).join('\n').trim();
71
+ if (chunk)
72
+ chunks.push({ content: chunk, lineStart: start + 1, lineEnd: end });
73
+ start = end;
74
+ }
75
+ return chunks;
76
+ }
77
+ function prepareIndexStatements(db) {
78
+ return {
79
+ deleteChunks: db.prepare('DELETE FROM knowledge_chunks_fts WHERE asset_id = ?'),
80
+ deleteAsset: db.prepare('DELETE FROM indexed_assets WHERE asset_id = ?'),
81
+ insertChunk: db.prepare('INSERT INTO knowledge_chunks_fts(asset_id, chunk_index, line_start, line_end, content) VALUES (?, ?, ?, ?, ?)'),
82
+ upsertStatus: db.prepare(`INSERT INTO indexed_assets(asset_id, content_hash, index_status, indexed_at)
83
+ VALUES (?, ?, ?, ?)
84
+ ON CONFLICT(asset_id) DO UPDATE SET
85
+ content_hash = excluded.content_hash,
86
+ index_status = excluded.index_status,
87
+ indexed_at = excluded.indexed_at`),
88
+ };
89
+ }
90
+ function removeIndexedAsset(statements, assetId) {
91
+ statements.deleteChunks.run(assetId);
92
+ statements.deleteAsset.run(assetId);
93
+ }
94
+ function setIndexStatus(statements, asset, status) {
95
+ statements.upsertStatus.run(asset.id, indexFingerprint(asset), status, new Date().toISOString());
96
+ }
97
+ async function indexAsset(asset, existing, statements) {
98
+ if (existing?.contentHash === indexFingerprint(asset) && existing.indexStatus === expectedIndexStatus(asset))
99
+ return;
100
+ removeIndexedAsset(statements, asset.id);
101
+ if (asset.syncState === 'missing') {
102
+ setIndexStatus(statements, asset, 'missing');
103
+ return;
104
+ }
105
+ if (!isIndexableText(asset) || asset.sizeBytes > MAX_INDEXED_FILE_BYTES) {
106
+ setIndexStatus(statements, asset, 'unsupported');
107
+ return;
108
+ }
109
+ try {
110
+ const bytes = await fs.promises.readFile(asset.path);
111
+ if (bytes.includes(0)) {
112
+ setIndexStatus(statements, asset, 'unsupported');
113
+ return;
114
+ }
115
+ const content = bytes.toString('utf8');
116
+ const chunks = textChunks(asset.entry ? `${asset.entry.title}\n\n${content}` : content);
117
+ chunks.forEach((chunk, index) => statements.insertChunk.run(asset.id, index, chunk.lineStart, chunk.lineEnd, chunk.content));
118
+ setIndexStatus(statements, asset, 'indexed');
119
+ }
120
+ catch {
121
+ removeIndexedAsset(statements, asset.id);
122
+ setIndexStatus(statements, asset, 'failed');
123
+ }
124
+ }
125
+ export async function syncLocalKnowledgeIndex(assets, userDataPath) {
126
+ const db = await openIndex(userDataPath);
127
+ try {
128
+ const statements = prepareIndexStatements(db);
129
+ const assetIds = new Set(assets.map((asset) => asset.id));
130
+ const indexed = new Map();
131
+ const staleAssetIds = [];
132
+ for (const row of db.prepare('SELECT asset_id, content_hash, index_status FROM indexed_assets').all()) {
133
+ const assetId = String(row.asset_id || '');
134
+ if (!assetId)
135
+ continue;
136
+ if (!assetIds.has(assetId)) {
137
+ staleAssetIds.push(assetId);
138
+ continue;
139
+ }
140
+ indexed.set(assetId, {
141
+ contentHash: String(row.content_hash || ''),
142
+ indexStatus: String(row.index_status || ''),
143
+ });
144
+ }
145
+ const changedAssets = assets.filter((asset) => {
146
+ const existing = indexed.get(asset.id);
147
+ return existing?.contentHash !== indexFingerprint(asset) || existing.indexStatus !== expectedIndexStatus(asset);
148
+ });
149
+ if (staleAssetIds.length === 0 && changedAssets.length === 0)
150
+ return;
151
+ db.exec('BEGIN IMMEDIATE');
152
+ try {
153
+ for (const assetId of staleAssetIds)
154
+ removeIndexedAsset(statements, assetId);
155
+ for (const asset of changedAssets)
156
+ await indexAsset(asset, indexed.get(asset.id), statements);
157
+ db.exec('COMMIT');
158
+ }
159
+ catch (error) {
160
+ db.exec('ROLLBACK');
161
+ throw error;
162
+ }
163
+ }
164
+ finally {
165
+ db.close();
166
+ }
167
+ }
168
+ function excerpt(content, query, maxCharacters = 240) {
169
+ const normalized = content.toLocaleLowerCase('zh-CN');
170
+ const index = normalized.indexOf(query.toLocaleLowerCase('zh-CN'));
171
+ if (index < 0 && content.length <= maxCharacters)
172
+ return content;
173
+ const start = Math.max(0, index < 0 ? 0 : index - Math.floor(maxCharacters / 3));
174
+ const value = content
175
+ .slice(start, start + maxCharacters)
176
+ .replace(/\s+/g, ' ')
177
+ .trim();
178
+ return `${start > 0 ? '…' : ''}${value}${start + maxCharacters < content.length ? '…' : ''}`;
179
+ }
180
+ function searchQueries(value, matchMode) {
181
+ const values = Array.isArray(value) ? value : [value];
182
+ const queries = [];
183
+ const seen = new Set();
184
+ for (const candidate of values) {
185
+ const query = candidate.trim();
186
+ const key = query.toLocaleLowerCase('zh-CN');
187
+ if (!query || seen.has(key))
188
+ continue;
189
+ seen.add(key);
190
+ queries.push(query);
191
+ if (queries.length >= 12)
192
+ break;
193
+ }
194
+ return matchMode === 'exact' && queries.length > 1 ? [queries.join(' ')] : queries;
195
+ }
196
+ function escapedLikePattern(value) {
197
+ return `%${value.replace(/([%_\\])/g, '\\$1')}%`;
198
+ }
199
+ function lineNumberAt(content, index) {
200
+ return content.slice(0, Math.max(0, index)).split('\n').length;
201
+ }
202
+ export async function searchLocalKnowledgeIndex(assets, query, limit, userDataPath, matchMode = 'any') {
203
+ await syncLocalKnowledgeIndex(assets, userDataPath);
204
+ const queries = searchQueries(query, matchMode);
205
+ const normalizedQueries = queries.map((item) => item.toLocaleLowerCase('zh-CN'));
206
+ if (queries.length === 0) {
207
+ return assets.slice(0, limit).map((asset) => ({
208
+ assetId: asset.id,
209
+ matchedQueries: [],
210
+ matches: [],
211
+ }));
212
+ }
213
+ const db = await openIndex(userDataPath);
214
+ try {
215
+ const byAsset = new Map();
216
+ const stateFor = (assetId, index) => {
217
+ const existing = byAsset.get(assetId);
218
+ if (existing)
219
+ return existing;
220
+ const created = {
221
+ index,
222
+ labelQueries: new Set(),
223
+ matchedQueries: new Set(),
224
+ matches: [],
225
+ };
226
+ byAsset.set(assetId, created);
227
+ return created;
228
+ };
229
+ const assetIndex = new Map(assets.map((asset, index) => [asset.id, index]));
230
+ for (const [index, asset] of assets.entries()) {
231
+ const label = `${asset.name}\n${asset.relativePath}`.toLocaleLowerCase('zh-CN');
232
+ normalizedQueries.forEach((needle, queryIndex) => {
233
+ if (!label.includes(needle))
234
+ return;
235
+ const state = stateFor(asset.id, index);
236
+ state.matchedQueries.add(queries[queryIndex]);
237
+ state.labelQueries.add(queries[queryIndex]);
238
+ });
239
+ }
240
+ const visibleAssets = new Set(assets.map((asset) => asset.id));
241
+ const candidateLimit = Math.min(2_000, Math.max(limit * 40, 200));
242
+ const longQueries = normalizedQueries.filter((item) => Array.from(item).length >= 3);
243
+ const shortQueries = normalizedQueries.filter((item) => Array.from(item).length < 3);
244
+ const rows = [];
245
+ if (longQueries.length > 0) {
246
+ const expression = longQueries.map((item) => `"${item.replaceAll('"', '""')}"`).join(' OR ');
247
+ rows.push(...db
248
+ .prepare(`SELECT asset_id, line_start, line_end, content
249
+ FROM knowledge_chunks_fts
250
+ WHERE knowledge_chunks_fts MATCH ?
251
+ ORDER BY bm25(knowledge_chunks_fts)
252
+ LIMIT ?`)
253
+ .all(expression, candidateLimit));
254
+ }
255
+ const globallySearchShortQueries = shortQueries.length > 0 && (matchMode !== 'all' || longQueries.length === 0);
256
+ if (globallySearchShortQueries) {
257
+ const predicates = shortQueries.map(() => "lower(content) LIKE ? ESCAPE '\\'").join(' OR ');
258
+ rows.push(...db
259
+ .prepare(`SELECT asset_id, line_start, line_end, content
260
+ FROM knowledge_chunks_fts
261
+ WHERE ${predicates}
262
+ LIMIT ?`)
263
+ .all(...shortQueries.map(escapedLikePattern), candidateLimit));
264
+ }
265
+ const seenMatches = new Set();
266
+ for (const row of rows) {
267
+ const assetId = String(row.asset_id || '');
268
+ if (!visibleAssets.has(assetId))
269
+ continue;
270
+ const content = String(row.content || '');
271
+ const normalizedContent = content.toLocaleLowerCase('zh-CN');
272
+ const matchedIndexes = normalizedQueries.flatMap((needle, index) => normalizedContent.includes(needle) ? [index] : []);
273
+ if (matchedIndexes.length === 0)
274
+ continue;
275
+ const state = stateFor(assetId, assetIndex.get(assetId) || 0);
276
+ for (const queryIndex of matchedIndexes) {
277
+ const matchedQuery = queries[queryIndex];
278
+ state.matchedQueries.add(matchedQuery);
279
+ const matchKey = `${assetId}:${row.line_start}:${row.line_end}:${matchedQuery}`;
280
+ if (state.matches.length >= 3 || seenMatches.has(matchKey))
281
+ continue;
282
+ seenMatches.add(matchKey);
283
+ state.matches.push({
284
+ query: matchedQuery,
285
+ excerpt: excerpt(content, matchedQuery),
286
+ lineStart: Number(row.line_start || 0),
287
+ lineEnd: Number(row.line_end || 0),
288
+ });
289
+ }
290
+ }
291
+ if (matchMode === 'all' && longQueries.length > 0 && shortQueries.length > 0) {
292
+ const longOriginalQueries = queries.filter((_, index) => Array.from(normalizedQueries[index]).length >= 3);
293
+ for (const [assetId, state] of byAsset) {
294
+ if (!longOriginalQueries.every((item) => state.matchedQueries.has(item)))
295
+ continue;
296
+ const asset = assets[assetIndex.get(assetId) ?? -1];
297
+ if (!asset || expectedIndexStatus(asset) !== 'indexed')
298
+ continue;
299
+ let content;
300
+ try {
301
+ content = fs.readFileSync(asset.path, 'utf8');
302
+ }
303
+ catch {
304
+ continue;
305
+ }
306
+ const normalizedContent = content.toLocaleLowerCase('zh-CN');
307
+ normalizedQueries.forEach((needle, queryIndex) => {
308
+ if (Array.from(needle).length >= 3 || !normalizedContent.includes(needle))
309
+ return;
310
+ const matchedQuery = queries[queryIndex];
311
+ state.matchedQueries.add(matchedQuery);
312
+ if (state.matches.length >= 3)
313
+ return;
314
+ const matchIndex = normalizedContent.indexOf(needle);
315
+ const lineNumber = lineNumberAt(content, matchIndex);
316
+ state.matches.push({
317
+ query: matchedQuery,
318
+ excerpt: excerpt(content, matchedQuery),
319
+ lineStart: lineNumber,
320
+ lineEnd: lineNumber,
321
+ });
322
+ });
323
+ }
324
+ }
325
+ return [...byAsset.entries()]
326
+ .filter(([, state]) => matchMode === 'all' ? state.matchedQueries.size === queries.length : state.matchedQueries.size > 0)
327
+ .sort(([, left], [, right]) => {
328
+ const leftScore = left.matchedQueries.size * 100 + left.labelQueries.size * 10 + left.matches.length;
329
+ const rightScore = right.matchedQueries.size * 100 + right.labelQueries.size * 10 + right.matches.length;
330
+ return rightScore - leftScore || left.index - right.index;
331
+ })
332
+ .slice(0, limit)
333
+ .map(([assetId, state]) => ({
334
+ assetId,
335
+ matchedQueries: [...state.matchedQueries],
336
+ matches: state.matches,
337
+ }));
338
+ }
339
+ finally {
340
+ db.close();
341
+ }
342
+ }
343
+ export async function readLocalKnowledgeIndexedContent(asset, truncate, userDataPath) {
344
+ const db = await openIndex(userDataPath);
345
+ try {
346
+ const statements = prepareIndexStatements(db);
347
+ const existingRow = db
348
+ .prepare('SELECT content_hash, index_status FROM indexed_assets WHERE asset_id = ?')
349
+ .get(asset.id);
350
+ const existing = existingRow
351
+ ? {
352
+ contentHash: String(existingRow.content_hash || ''),
353
+ indexStatus: String(existingRow.index_status || ''),
354
+ }
355
+ : undefined;
356
+ if (existing?.contentHash !== indexFingerprint(asset) || existing.indexStatus !== expectedIndexStatus(asset)) {
357
+ db.exec('BEGIN IMMEDIATE');
358
+ try {
359
+ await indexAsset(asset, existing, statements);
360
+ db.exec('COMMIT');
361
+ }
362
+ catch (error) {
363
+ db.exec('ROLLBACK');
364
+ throw error;
365
+ }
366
+ }
367
+ const statusRow = db.prepare('SELECT index_status FROM indexed_assets WHERE asset_id = ?').get(asset.id);
368
+ const indexStatus = String(statusRow?.index_status || 'failed');
369
+ if (indexStatus !== 'indexed') {
370
+ return { content: null, contentAvailable: false, truncated: false, indexStatus };
371
+ }
372
+ if (asset.entry) {
373
+ const content = await fs.promises.readFile(asset.path, 'utf8');
374
+ return {
375
+ content: content.slice(0, truncate),
376
+ contentAvailable: true,
377
+ truncated: content.length > truncate,
378
+ indexStatus,
379
+ };
380
+ }
381
+ const rows = db
382
+ .prepare('SELECT content FROM knowledge_chunks_fts WHERE asset_id = ? ORDER BY CAST(chunk_index AS INTEGER)')
383
+ .all(asset.id);
384
+ const content = rows
385
+ .map((row) => String(row.content || ''))
386
+ .filter(Boolean)
387
+ .join('\n\n');
388
+ return {
389
+ content: content.slice(0, truncate),
390
+ contentAvailable: true,
391
+ truncated: content.length > truncate,
392
+ indexStatus,
393
+ };
394
+ }
395
+ finally {
396
+ db.close();
397
+ }
398
+ }