@openturtle/cli 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,357 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { syncLocalKnowledgeIndex } from './index.js';
5
+ import { localKnowledgeStoreRoot } from './root.js';
6
+ const MAX_LOCAL_LIBRARY_FILES = 5_000;
7
+ const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', 'build', 'coverage', '.git']);
8
+ function libraryPath(userDataPath = localKnowledgeStoreRoot()) {
9
+ return path.join(userDataPath, 'knowledge', 'local-assets.json');
10
+ }
11
+ function entriesPath(userDataPath = localKnowledgeStoreRoot()) {
12
+ return path.join(userDataPath, 'knowledge', 'entries');
13
+ }
14
+ function mimeType(filePath) {
15
+ const extension = path.extname(filePath).slice(1).toLowerCase();
16
+ const known = {
17
+ csv: 'text/csv',
18
+ gif: 'image/gif',
19
+ html: 'text/html',
20
+ jpeg: 'image/jpeg',
21
+ jpg: 'image/jpeg',
22
+ json: 'application/json',
23
+ md: 'text/markdown',
24
+ markdown: 'text/markdown',
25
+ pdf: 'application/pdf',
26
+ png: 'image/png',
27
+ ppt: 'application/vnd.ms-powerpoint',
28
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
29
+ txt: 'text/plain',
30
+ webp: 'image/webp',
31
+ xls: 'application/vnd.ms-excel',
32
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
33
+ yaml: 'application/yaml',
34
+ yml: 'application/yaml',
35
+ };
36
+ return known[extension] || 'application/octet-stream';
37
+ }
38
+ function assetId(filePath) {
39
+ return crypto.createHash('sha256').update(path.resolve(filePath)).digest('hex').slice(0, 24);
40
+ }
41
+ async function fileHash(filePath) {
42
+ const hash = crypto.createHash('sha256');
43
+ await new Promise((resolve, reject) => {
44
+ const stream = fs.createReadStream(filePath);
45
+ stream.on('data', (chunk) => hash.update(chunk));
46
+ stream.on('error', reject);
47
+ stream.on('end', resolve);
48
+ });
49
+ return hash.digest('hex');
50
+ }
51
+ function entryHash(asset) {
52
+ const entry = asset.entry;
53
+ if (!entry)
54
+ return asset.contentHash;
55
+ return crypto
56
+ .createHash('sha256')
57
+ .update(JSON.stringify({
58
+ contentHash: asset.contentHash,
59
+ title: entry.title,
60
+ kind: entry.kind,
61
+ projectId: entry.projectId || '',
62
+ status: entry.status,
63
+ attachments: entry.attachments.map((attachment) => ({
64
+ path: attachment.path,
65
+ contentHash: attachment.contentHash,
66
+ })),
67
+ }))
68
+ .digest('hex');
69
+ }
70
+ function writeTextFileAtomic(filePath, content) {
71
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
72
+ const tempPath = `${filePath}.${process.pid}.tmp`;
73
+ fs.writeFileSync(tempPath, content, 'utf8');
74
+ fs.renameSync(tempPath, filePath);
75
+ }
76
+ async function buildLocalAttachment(input) {
77
+ const filePath = path.resolve(input.path);
78
+ const stat = await fs.promises.stat(filePath);
79
+ if (!stat.isFile())
80
+ throw new Error(`附件不是可读取的文件:${input.name || filePath}`);
81
+ return {
82
+ name: input.name.trim() || path.basename(filePath),
83
+ path: filePath,
84
+ mimeType: mimeType(filePath),
85
+ sizeBytes: stat.size,
86
+ modifiedAt: stat.mtimeMs,
87
+ contentHash: await fileHash(filePath),
88
+ };
89
+ }
90
+ function readLibrary(userDataPath) {
91
+ try {
92
+ const parsed = JSON.parse(fs.readFileSync(libraryPath(userDataPath), 'utf8'));
93
+ return {
94
+ version: 1,
95
+ assets: Array.isArray(parsed.assets) ? parsed.assets : [],
96
+ };
97
+ }
98
+ catch {
99
+ return { version: 1, assets: [] };
100
+ }
101
+ }
102
+ function writeLibrary(library, userDataPath) {
103
+ const filePath = libraryPath(userDataPath);
104
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
105
+ const tempPath = `${filePath}.${process.pid}.tmp`;
106
+ fs.writeFileSync(tempPath, JSON.stringify(library, null, 2), 'utf8');
107
+ fs.renameSync(tempPath, filePath);
108
+ }
109
+ async function collectFiles(inputPath) {
110
+ const resolved = path.resolve(inputPath);
111
+ const stat = await fs.promises.stat(resolved);
112
+ if (stat.isFile())
113
+ return [{ filePath: resolved, sourceRoot: path.dirname(resolved) }];
114
+ if (!stat.isDirectory())
115
+ return [];
116
+ const files = [];
117
+ const visit = async (directory) => {
118
+ if (files.length >= MAX_LOCAL_LIBRARY_FILES)
119
+ return;
120
+ const entries = await fs.promises.readdir(directory, { withFileTypes: true });
121
+ for (const entry of entries) {
122
+ if (files.length >= MAX_LOCAL_LIBRARY_FILES)
123
+ return;
124
+ if (entry.name.startsWith('.'))
125
+ continue;
126
+ const entryPath = path.join(directory, entry.name);
127
+ if (entry.isDirectory()) {
128
+ if (!SKIPPED_DIRECTORIES.has(entry.name))
129
+ await visit(entryPath);
130
+ }
131
+ else if (entry.isFile()) {
132
+ files.push({ filePath: entryPath, sourceRoot: resolved });
133
+ }
134
+ }
135
+ };
136
+ await visit(resolved);
137
+ return files;
138
+ }
139
+ async function refreshedAsset(asset) {
140
+ try {
141
+ const stat = await fs.promises.stat(asset.path);
142
+ if (!stat.isFile())
143
+ return { ...asset, syncState: 'missing' };
144
+ const changed = stat.size !== asset.sizeBytes || stat.mtimeMs !== asset.modifiedAt;
145
+ const contentHash = changed ? await fileHash(asset.path) : asset.contentHash;
146
+ let refreshed = {
147
+ ...asset,
148
+ sizeBytes: stat.size,
149
+ modifiedAt: stat.mtimeMs,
150
+ contentHash,
151
+ };
152
+ if (asset.entry) {
153
+ const attachments = await Promise.all(asset.entry.attachments.map(async (attachment) => {
154
+ try {
155
+ const attachmentStat = await fs.promises.stat(attachment.path);
156
+ if (!attachmentStat.isFile())
157
+ return null;
158
+ const attachmentChanged = attachmentStat.size !== attachment.sizeBytes || attachmentStat.mtimeMs !== attachment.modifiedAt;
159
+ return {
160
+ ...attachment,
161
+ sizeBytes: attachmentStat.size,
162
+ modifiedAt: attachmentStat.mtimeMs,
163
+ contentHash: attachmentChanged ? await fileHash(attachment.path) : attachment.contentHash,
164
+ };
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ }));
170
+ if (attachments.some((attachment) => attachment === null)) {
171
+ return { ...refreshed, syncState: 'missing' };
172
+ }
173
+ refreshed = {
174
+ ...refreshed,
175
+ entry: {
176
+ ...asset.entry,
177
+ attachments: attachments,
178
+ },
179
+ };
180
+ }
181
+ const syncState = asset.entry
182
+ ? asset.cloudMemoryId && asset.cloudEntryHash === entryHash(refreshed)
183
+ ? 'synced'
184
+ : asset.cloudMemoryId
185
+ ? 'outdated'
186
+ : 'local_only'
187
+ : asset.cloudAttachmentId
188
+ ? asset.cloudContentHash === contentHash
189
+ ? 'synced'
190
+ : 'outdated'
191
+ : 'local_only';
192
+ return { ...refreshed, syncState };
193
+ }
194
+ catch {
195
+ return { ...asset, syncState: 'missing' };
196
+ }
197
+ }
198
+ export async function listLocalKnowledgeAssets(userDataPath) {
199
+ const library = readLibrary(userDataPath);
200
+ const assets = await Promise.all(library.assets.map(refreshedAsset));
201
+ if (JSON.stringify(assets) !== JSON.stringify(library.assets)) {
202
+ writeLibrary({ version: 1, assets }, userDataPath);
203
+ }
204
+ await syncLocalKnowledgeIndex(assets, userDataPath);
205
+ return assets.sort((left, right) => right.addedAt.localeCompare(left.addedAt));
206
+ }
207
+ export async function importLocalKnowledgePaths(inputPaths, userDataPath) {
208
+ const collected = (await Promise.all(inputPaths.map(collectFiles))).flat().slice(0, MAX_LOCAL_LIBRARY_FILES);
209
+ const library = readLibrary(userDataPath);
210
+ const byId = new Map(library.assets.map((asset) => [asset.id, asset]));
211
+ const imported = [];
212
+ for (const { filePath, sourceRoot } of collected) {
213
+ const stat = await fs.promises.stat(filePath);
214
+ const id = assetId(filePath);
215
+ const existing = byId.get(id);
216
+ const contentHash = await fileHash(filePath);
217
+ const asset = {
218
+ ...existing,
219
+ id,
220
+ name: path.basename(filePath),
221
+ path: filePath,
222
+ sourceRoot,
223
+ relativePath: path.relative(sourceRoot, filePath).split(path.sep).join('/'),
224
+ mimeType: mimeType(filePath),
225
+ sizeBytes: stat.size,
226
+ modifiedAt: stat.mtimeMs,
227
+ contentHash,
228
+ addedAt: existing?.addedAt || new Date().toISOString(),
229
+ syncState: existing?.cloudAttachmentId
230
+ ? existing.cloudContentHash === contentHash
231
+ ? 'synced'
232
+ : 'outdated'
233
+ : 'local_only',
234
+ };
235
+ byId.set(id, asset);
236
+ imported.push(asset);
237
+ }
238
+ const assets = [...byId.values()];
239
+ writeLibrary({ version: 1, assets }, userDataPath);
240
+ await syncLocalKnowledgeIndex(assets, userDataPath);
241
+ return imported;
242
+ }
243
+ export async function createLocalKnowledgeEntry(input, userDataPath) {
244
+ const title = input.title.trim();
245
+ if (!title)
246
+ throw new Error('本机知识标题不能为空');
247
+ const id = crypto.randomUUID();
248
+ const filePath = path.join(entriesPath(userDataPath), `${id}.md`);
249
+ writeTextFileAtomic(filePath, input.body.trim());
250
+ const stat = await fs.promises.stat(filePath);
251
+ const attachments = await Promise.all((input.attachments || []).slice(0, 20).map(buildLocalAttachment));
252
+ const now = new Date().toISOString();
253
+ const asset = {
254
+ id,
255
+ name: `${title}.md`,
256
+ path: filePath,
257
+ sourceRoot: entriesPath(userDataPath),
258
+ relativePath: `${id}.md`,
259
+ mimeType: 'text/markdown',
260
+ sizeBytes: stat.size,
261
+ modifiedAt: stat.mtimeMs,
262
+ contentHash: await fileHash(filePath),
263
+ addedAt: now,
264
+ syncState: 'local_only',
265
+ entry: {
266
+ title,
267
+ kind: input.kind,
268
+ ...(input.projectId?.trim() ? { projectId: input.projectId.trim() } : {}),
269
+ status: input.status,
270
+ updatedAt: now,
271
+ attachments,
272
+ },
273
+ };
274
+ const library = readLibrary(userDataPath);
275
+ const assets = [asset, ...library.assets];
276
+ writeLibrary({ version: 1, assets }, userDataPath);
277
+ await syncLocalKnowledgeIndex(assets, userDataPath);
278
+ return asset;
279
+ }
280
+ export async function updateLocalKnowledgeEntry(id, input, userDataPath) {
281
+ const library = readLibrary(userDataPath);
282
+ const index = library.assets.findIndex((asset) => asset.id === id);
283
+ if (index < 0 || !library.assets[index].entry)
284
+ throw new Error('本机知识不存在或不能直接编辑');
285
+ const title = input.title.trim();
286
+ if (!title)
287
+ throw new Error('本机知识标题不能为空');
288
+ const existing = library.assets[index];
289
+ writeTextFileAtomic(existing.path, input.body.trim());
290
+ const stat = await fs.promises.stat(existing.path);
291
+ const attachments = await Promise.all((input.attachments || []).slice(0, 20).map(buildLocalAttachment));
292
+ const updated = {
293
+ ...existing,
294
+ name: `${title}.md`,
295
+ sizeBytes: stat.size,
296
+ modifiedAt: stat.mtimeMs,
297
+ contentHash: await fileHash(existing.path),
298
+ syncState: existing.cloudMemoryId ? 'outdated' : 'local_only',
299
+ entry: {
300
+ title,
301
+ kind: input.kind,
302
+ ...(input.projectId?.trim() ? { projectId: input.projectId.trim() } : {}),
303
+ status: input.status,
304
+ updatedAt: new Date().toISOString(),
305
+ attachments,
306
+ },
307
+ };
308
+ library.assets[index] = updated;
309
+ writeLibrary(library, userDataPath);
310
+ await syncLocalKnowledgeIndex(library.assets, userDataPath);
311
+ return updated;
312
+ }
313
+ export async function removeLocalKnowledgeAsset(id, userDataPath) {
314
+ const library = readLibrary(userDataPath);
315
+ const removed = library.assets.find((asset) => asset.id === id);
316
+ const assets = library.assets.filter((asset) => asset.id !== id);
317
+ writeLibrary({ version: 1, assets }, userDataPath);
318
+ if (removed?.entry && path.dirname(removed.path) === entriesPath(userDataPath)) {
319
+ await fs.promises.rm(removed.path, { force: true });
320
+ }
321
+ await syncLocalKnowledgeIndex(assets, userDataPath);
322
+ return assets;
323
+ }
324
+ export function markLocalKnowledgeAssetSynced(id, cloud, userDataPath) {
325
+ const library = readLibrary(userDataPath);
326
+ const index = library.assets.findIndex((asset) => asset.id === id);
327
+ if (index < 0)
328
+ return null;
329
+ const asset = {
330
+ ...library.assets[index],
331
+ cloudMemoryId: cloud.memoryId,
332
+ cloudAttachmentId: cloud.attachmentId,
333
+ cloudContentHash: cloud.contentHash,
334
+ syncState: library.assets[index].contentHash === cloud.contentHash ? 'synced' : 'outdated',
335
+ };
336
+ library.assets[index] = asset;
337
+ writeLibrary(library, userDataPath);
338
+ return asset;
339
+ }
340
+ export function markLocalKnowledgeEntryPublished(id, cloud, userDataPath) {
341
+ const library = readLibrary(userDataPath);
342
+ const index = library.assets.findIndex((asset) => asset.id === id);
343
+ if (index < 0 || !library.assets[index].entry)
344
+ return null;
345
+ const asset = {
346
+ ...library.assets[index],
347
+ cloudMemoryId: cloud.memoryId,
348
+ cloudEntryHash: entryHash(library.assets[index]),
349
+ syncState: 'synced',
350
+ };
351
+ library.assets[index] = asset;
352
+ writeLibrary(library, userDataPath);
353
+ return asset;
354
+ }
355
+ export function getLocalKnowledgeAsset(id, userDataPath) {
356
+ return readLibrary(userDataPath).assets.find((asset) => asset.id === id) || null;
357
+ }
@@ -0,0 +1,5 @@
1
+ import os from 'node:os';
2
+ import { cliGlobalDir } from '../core/paths.js';
3
+ export function localKnowledgeStoreRoot(homeDir = os.homedir()) {
4
+ return process.env.OPENTURTLE_LOCAL_KNOWLEDGE_ROOT?.trim() || cliGlobalDir(homeDir);
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openturtle/cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "OpenTurtle collaboration, knowledge, meeting, and agent CLI",
5
5
  "keywords": [
6
6
  "openturtle",
@@ -25,7 +25,9 @@
25
25
  },
26
26
  "files": [
27
27
  "dist",
28
- "README.md"
28
+ "skill",
29
+ "README.md",
30
+ "RELEASE_RUNBOOK.md"
29
31
  ],
30
32
  "publishConfig": {
31
33
  "access": "public",
@@ -0,0 +1,81 @@
1
+ ---
2
+ name: openturtle-cli
3
+ description: 通过 ot CLI 使用 OpenTurtle 的项目、团队成员、项目上下文、云端与本地知识、会议、目标、待办和工作记录,并在 Desktop 中依据已注入的 Connector 清单选择外部服务 CLI。适用于团队协同、聚合知识检索、会议闭环、项目执行和已连接工具路由;Desktop 会话、自动任务、录音和窗口能力不属于 ot,会话内 Goal 使用当前 Provider 官方 /goal。
4
+ ---
5
+
6
+ # OpenTurtle CLI
7
+
8
+ 直接执行 `ot ... --json`。不要安装或调用 OpenTurtle MCP、Hook,也不要把 Desktop 独有能力伪装成 CLI 命令。
9
+
10
+ Desktop 会在隐藏系统上下文中注入已启用 Connector 的命令、能力摘要、发现命令、鉴权归属和确认策略。只使用该清单中的 Connector;需要核对时调用 `openturtle-desktop` 的 `list_connectors`。不要调用或设计 `ot connectors`。
11
+
12
+ 用户要求新增外部服务时,先准备符合 `schemaVersion: 1` 的 manifest,再调用 `openturtle-desktop` 的 `propose_connector`。该调用只提交待审批提案;用户必须在“扩展 > 连接器”批准,安装和写操作仍需单独确认。不得直接修改 Desktop 配置或 Connector 目录。
13
+
14
+ 开始前运行:
15
+
16
+ ```bash
17
+ command -v ot
18
+ ot --json doctor
19
+ ```
20
+
21
+ 缺少登录态时让用户运行 `ot auth login --web`,不要索取或输出 token。Desktop 内置 Agent 会自动复用 Desktop 当前登录态、团队和项目上下文;独立终端使用 CLI 自己的登录配置。
22
+
23
+ ## 读取流程
24
+
25
+ 1. 用 `ot projects list` 或 `ot projects resolve <query>` 定位项目。
26
+ 2. 用 `ot roster list --project <id>` 解析成员;先匹配 canonical `display_name`,再参考 aliases 和项目角色,重名或无法唯一匹配时询问用户。
27
+ 3. 用 `ot context get --project <id> --query <term>` 获取项目上下文;复杂问题拆成少量明确查询词。
28
+ 4. 按对象读取:`ot work summary`、`ot goals list/show`、`ot todos list/show`、`ot knowledge list/show/search`、`ot knowledge local list/search/read`、`ot meetings list/show/transcript/minutes/participants`、`ot worklogs list`。
29
+ 5. 高层命令缺失时才使用只读逃生口 `ot request get /api/...`。
30
+
31
+ ## 写入规则
32
+
33
+ - 所有结构化写入使用 `--body-file <json>` 或 `--body-file -`,避免 shell 转义损坏内容。
34
+ - 先执行同一命令的 `--dry-run`,检查 `request.path` 和 `request.body`;用户确认后再正式写入。
35
+ - 创建目标、Todo、正式决策/风险、提交审核、审核通过/驳回、确认会议项目更新前,必须展示明确草稿和影响。
36
+ - 项目归属只能来自用户明确选择,或受管 Goal/Meeting 唯一解析出的项目;无法唯一确定时停止写入并询问。
37
+ - Knowledge 保存可长期复用的结论和完整会议纪要;短进展、决策、风险和产物引用写入 WorkLog。原始文件是 Knowledge 的来源材料,不是第二套知识系统。
38
+
39
+ ## Todo 与 Goal
40
+
41
+ Todo 草稿至少包含来源、范围/环境、2-5 个行动步骤、可判断的验收标准、证据、负责人和截止时间。信息不全时先问一个最关键的问题,不擅自指派。
42
+
43
+ `ot goals` 管理 OpenTurtle 团队/项目目标。当前 Agent 会话的 Goal 使用 Provider 官方 `/goal` 命令;不要将会话 Goal 写入 OpenTurtle 项目目标,也不要尝试同步两者状态。
44
+
45
+ ```bash
46
+ ot goals create --body-file goal.json --dry-run
47
+ ot todos create --body-file todo.json --dry-run
48
+ ot todos update <todo-id> --body-file todo-update.json --dry-run
49
+ ot todos submit-review <todo-id> --body-file review.json --dry-run
50
+ ot todos review <todo-id> --body-file decision.json --dry-run
51
+ ```
52
+
53
+ ## Knowledge
54
+
55
+ 保存前展示标题、摘要、正文范围、Project/Goal、来源 URL、附件和上下文引用。只有用户明确选择并确认的结论才能沉淀;不得自动上传或持久化 Desktop 既有会话历史。
56
+
57
+ ```bash
58
+ ot knowledge search "关键词" --project <project-id>
59
+ ot knowledge local search "关键词"
60
+ ot knowledge local read <asset-id>
61
+ ot knowledge local import ./docs --dry-run
62
+ ot knowledge save --body-file knowledge.json --dry-run
63
+ ot knowledge attach <knowledge-id> ./artifact.pdf --dry-run
64
+ ```
65
+
66
+ `ot knowledge search` 默认聚合云端知识和本地索引;仅查一侧时显式使用 `--cloud-only` 或 `--local-only`。本地知识由 CLI 维护,Desktop 知识库界面复用同一实现和原有数据目录。
67
+
68
+ ## Meeting
69
+
70
+ 1. 先解析 Project、Roster 和 Project Context。
71
+ 2. 上传录音或读取已有 Meeting;区分事实、讨论、建议、已确认决策和不确定负责人。
72
+ 3. 用 `ot meetings publish <meeting-id> --dry-run` 将完整纪要发布为 `kind=meeting` Knowledge。
73
+ 4. 用 `ot meetings updates list <meeting-id>` 展示 action、research、decision、risk 草稿,允许逐条编辑或忽略。
74
+ 5. 只有用户确认具体条目后,才用 `ot meetings updates confirm <meeting-id> <update-id...> --knowledge <knowledge-id> --dry-run` 物化;没有已保存的 meeting Knowledge ID 时不得确认。
75
+ 6. 同一候选只走一次物化路径,禁止重复创建 Todo 或 WorkLog。
76
+
77
+ ## Desktop 边界
78
+
79
+ 以下能力继续使用 Desktop 界面或 `openturtle-desktop` MCP,不调用 `ot`:会话导入/读取/恢复/Fork、本地自动任务调度与 Agent 执行、麦克风和实时录音、文件选择、窗口/托盘/系统通知、跨设备运行时。Desktop 本地知识不在此边界内,统一走 `ot knowledge local ...`。会话内 Goal 直接使用当前 Provider 官方 `/goal`,不依赖 Desktop 自研状态或 OpenTurtle MCP。
80
+
81
+ 飞书/Lark 能力直接使用官方 `lark-cli`。需要会议工作流时先执行 `lark-cli skills read lark-workflow-meeting-summary --json`,再按其中提示读取 `lark-shared`、`lark-vc` 等内嵌 Skill 内容;不要安装整套 `lark-*` Skills。