@helpfeel/cosense-cli 1.17.1 → 1.18.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helpfeel/cosense-cli",
3
- "version": "1.17.1",
3
+ "version": "1.18.0",
4
4
  "description": "Cosense (旧Scrapbox) のページを読み・調べ・編集するAgent Skillのハーネス",
5
5
  "homepage": "https://github.com/helpfeel/cosense-cli",
6
6
  "license": "MIT",
@@ -29,8 +29,8 @@
29
29
  "devDependencies": {
30
30
  "@types/node": "24.19.2",
31
31
  "npm-run-all": "4.1.5",
32
- "oxfmt": "0.72.0",
33
- "oxlint": "1.87.0",
32
+ "oxfmt": "0.73.0",
33
+ "oxlint": "1.88.0",
34
34
  "typescript": "7.0.2"
35
35
  },
36
36
  "engines": {
@@ -13,20 +13,25 @@ import {
13
13
  import { resolveCredential, type Credential } from '../lib/settings.ts';
14
14
 
15
15
  export const browsePageChangesSummary =
16
- 'ページの編集履歴(commit)をpageId起点で取得し、誰がいつ何をどう変えたかを自然言語で説明する。タイトル変更(リネーム)も検出する';
16
+ 'ページの最近の編集履歴(commit)をpageId起点で取得し、誰がいつ何をどう変えたかを自然言語で説明する。タイトル変更(リネーム)も検出する';
17
17
 
18
- export const browsePageChangesHelp = `browsePageChanges - ページの編集履歴(commit)をpageId起点で取得し自然言語で説明する
18
+ export const browsePageChangesHelp = `browsePageChanges - ページの最近の編集履歴(commit)をpageId起点で取得し自然言語で説明する
19
19
 
20
20
  Usage:
21
- cosense browsePageChanges <projectUrl> <pageId> [--since <commitId>]
21
+ cosense browsePageChanges <projectUrl> <pageId> [--since <commitId> | [--before <commitId>] [--all]]
22
22
 
23
23
  引数:
24
- <projectUrl> プロジェクトのURL(例: https://scrapbox.io/shokai)
25
- <pageId> ページの不変ID。browsePage / readPage の出力に含まれる
26
- --since <commitId> 指定した commitId より後の変更だけを対象にする(カーソル)。
27
- browsePage / readPage 出力の commitId を控えておき、それを渡すと
28
- 「前回読んだ後に何が変わったか」だけを取得できる。
29
- 省略すると全履歴を対象にする。
24
+ <projectUrl> プロジェクトのURL(例: https://scrapbox.io/shokai)
25
+ <pageId> ページの不変ID。browsePage / readPage の出力に含まれる
26
+ --since <commitId> 指定した commitId より後の変更だけを対象にする(カーソル)。
27
+ browsePage / readPage 出力の commitId を控えておき、それを渡すと
28
+ 「前回読んだ後に何が変わったか」だけを取得できる。
29
+ --before <commitId> 指定した commitId より前の変更を、新しい方から最大100件対象にする。
30
+ 前回の出力の「さらに前の変更を読むコマンド」をそのまま実行すると続きを読める。
31
+ --all 保持中の全履歴を対象にする。履歴の長いページでは要求が何回にも分かれる。
32
+ --before と併用すると、その commitId より前の全履歴になる。
33
+ どれも省略すると、最新の変更を新しい方から最大100件対象にする。
34
+ --since は --before / --all と併用できない。
30
35
 
31
36
  なぜpageId起点か:
32
37
  ページタイトルはURLになるため、共同編集者がタイトルを変更すると旧タイトルでは読めなくなる。
@@ -34,8 +39,11 @@ Usage:
34
39
 
35
40
  出力形式(Markdown plain text。JSONではない):
36
41
  # ページ変更履歴
37
- pageId / 範囲(--since より後か全履歴か)/ commit数 / title change(変更後タイトル、無ければ なし)
38
- タイトルが変わっていた時のみ、新URLと新Readable linkも出力する
42
+ pageId / 範囲 / commit数 / title change(範囲内の最後の変更後タイトル、無ければ なし)
43
+ 範囲外のタイトル変更は検出しないので、無しでもリネーム済みの事がある。
44
+ 範囲が最新の変更を含む時(--before 無し)は、タイトルが変わっていれば新URLと新Readable linkも出力する。
45
+ --before 付きの title change は当時のタイトルで、現在のタイトルとは限らないのでURLは出さない。
46
+ 既定と --before(--all 無し)で範囲より前にも変更がある時は「さらに前の変更を読むコマンド」に、続きを読む完全なコマンドを示す
39
47
  ## 変更
40
48
  時系列の変更イベントを「<日時>\t<誰>が<何を>」の形(TAB区切り)で列挙する。
41
49
  同一行への連続した編集は1件にまとめ、変更前→最終のテキストを示す。
@@ -73,22 +81,32 @@ interface ParsedArgs {
73
81
  projectUrl: string;
74
82
  pageId: string;
75
83
  since: string | undefined;
84
+ before: string | undefined;
85
+ all: boolean;
76
86
  }
77
87
 
78
88
  const parseArgs = (args: string[]): ParsedArgs => {
79
89
  const usage =
80
- 'Usage: cosense browsePageChanges <projectUrl> <pageId> [--since <commitId>]';
90
+ 'Usage: cosense browsePageChanges <projectUrl> <pageId> [--since <commitId> | [--before <commitId>] [--all]]';
81
91
  let since: string | undefined;
92
+ let before: string | undefined;
93
+ let all = false;
82
94
  const positional: string[] = [];
83
95
  for (let i = 0; i < args.length; i += 1) {
84
96
  const arg = args[i] as string;
85
- if (arg === '--since') {
97
+ if (arg === '--since' || arg === '--before') {
86
98
  const value = args[i + 1];
87
99
  if (value === undefined || value.startsWith('--')) {
88
- throw new Error(`--since requires a commitId\n${usage}`);
100
+ throw new Error(`${arg} requires a commitId\n${usage}`);
101
+ }
102
+ if (arg === '--since') {
103
+ since = value;
104
+ } else {
105
+ before = value;
89
106
  }
90
- since = value;
91
107
  i += 1;
108
+ } else if (arg === '--all') {
109
+ all = true;
92
110
  } else if (arg.startsWith('--')) {
93
111
  throw new Error(`Unknown option: ${arg}\n${usage}`);
94
112
  } else {
@@ -98,10 +116,15 @@ const parseArgs = (args: string[]): ParsedArgs => {
98
116
  if (positional.length !== 2) {
99
117
  throw new Error(usage);
100
118
  }
119
+ if (since !== undefined && (before !== undefined || all)) {
120
+ throw new Error(`--since cannot be used with --before or --all\n${usage}`);
121
+ }
101
122
  return {
102
123
  projectUrl: positional[0] as string,
103
124
  pageId: positional[1] as string,
104
- since
125
+ since,
126
+ before,
127
+ all
105
128
  };
106
129
  };
107
130
 
@@ -119,17 +142,18 @@ const resolveUserName = (
119
142
  const quote = (text: string | undefined): string => `「${text ?? ''}」`;
120
143
 
121
144
  // 1回の応答は件数と大きさで打ち切られ、続きがある時はhasMoreが真になる
122
- const fetchAllCommits = async (
145
+ // headより後のcommitを古い順に、headを最後のcommitに付け替えて全部取る
146
+ const fetchCommitsAfter = async (
123
147
  apiUrl: string,
124
- since: string | undefined,
148
+ since: string,
125
149
  credential: Credential | undefined
126
150
  ): Promise<Commit[]> => {
127
151
  const commits: Commit[] = [];
128
152
  let head = since;
129
153
  let continuing = false;
130
- const requestedHeads = new Set<string>(since ? [since] : []);
154
+ const requestedHeads = new Set<string>([since]);
131
155
  while (true) {
132
- const url = head ? `${apiUrl}?head=${encodeURIComponent(head)}` : apiUrl;
156
+ const url = `${apiUrl}?head=${encodeURIComponent(head)}`;
133
157
  const data = (await requestJson(url, { credential })) as CommitsResponse;
134
158
  const batch = data.commits ?? [];
135
159
  const first = batch[0];
@@ -138,7 +162,7 @@ const fetchAllCommits = async (
138
162
  throw new Error(`no commits follow head "${head}"`);
139
163
  }
140
164
  // headに続かない応答を辿ると、同じ応答を取り続けて終わらないので止める
141
- if (head && first && first.parentId !== head) {
165
+ if (first && first.parentId !== head) {
142
166
  throw new Error(`commits do not follow head "${head}"`);
143
167
  }
144
168
  // spreadで足すと、1回の応答が大きい時に引数の上限を超える
@@ -158,6 +182,56 @@ const fetchAllCommits = async (
158
182
  }
159
183
  };
160
184
 
185
+ // 最新のcommit(beforeがあればその親)から親を辿った新しい順の応答を、allなら全部、でなければ1回分だけ取る
186
+ // olderCommitIdは、取った範囲より前に変更が残っている時の、続きを取るbefore
187
+ const fetchCommitsBefore = async (
188
+ apiUrl: string,
189
+ { before, all }: { before: string | undefined; all: boolean },
190
+ credential: Credential | undefined
191
+ ): Promise<{ commits: Commit[]; olderCommitId: string | undefined }> => {
192
+ const commits: Commit[] = [];
193
+ let cursor = before;
194
+ // 前の応答の最後のcommitの親。続きの応答の先頭はこれでなければならない
195
+ let expectedFirstId: string | null | undefined;
196
+ const requestedCursors = new Set<string>(before ? [before] : []);
197
+ while (true) {
198
+ const url = cursor
199
+ ? `${apiUrl}?before=${encodeURIComponent(cursor)}`
200
+ : apiUrl;
201
+ const data = (await requestJson(url, { credential })) as CommitsResponse;
202
+ const batch = data.commits ?? [];
203
+ const first = batch[0];
204
+ // 古いserverはhead無しの応答を古い順に返す。隣り合う2件の親子の向きで見分け、古い100件を最新として出さない
205
+ const second = batch[1];
206
+ if (first && second && second.parentId === first.id) {
207
+ throw new Error(
208
+ 'the server returned commits from the oldest. This version of cosense-cli needs a server that returns them from the latest'
209
+ );
210
+ }
211
+ if (expectedFirstId !== undefined) {
212
+ if (!first) {
213
+ throw new Error(`no commits precede "${cursor}"`);
214
+ }
215
+ if (first.id !== expectedFirstId) {
216
+ throw new Error(`commits do not precede "${cursor}"`);
217
+ }
218
+ }
219
+ for (const commit of batch) commits.push(commit);
220
+ const last = batch.at(-1);
221
+ if (!data.hasMore) return { commits, olderCommitId: undefined };
222
+ if (!last?.id) {
223
+ throw new Error('hasMore is true but the last commit has no id');
224
+ }
225
+ if (!all) return { commits, olderCommitId: last.id };
226
+ if (requestedCursors.has(last.id)) {
227
+ throw new Error(`commit "${last.id}" appears again as before`);
228
+ }
229
+ requestedCursors.add(last.id);
230
+ cursor = last.id;
231
+ expectedFirstId = last.parentId ?? null;
232
+ }
233
+ };
234
+
161
235
  // 時系列の変更イベント。同一行への連続updateは1件に畳む
162
236
  type ChangeEvent =
163
237
  | { kind: 'title'; userIds: string[]; created?: number; title: string }
@@ -243,15 +317,45 @@ const renderEvent = (event: ChangeEvent, userMap: UserMap): string => {
243
317
  }
244
318
  };
245
319
 
320
+ const fetchCommits = async (
321
+ apiUrl: string,
322
+ { since, before, all }: Pick<ParsedArgs, 'since' | 'before' | 'all'>,
323
+ credential: Credential | undefined
324
+ ): Promise<{ commits: Commit[]; olderCommitId: string | undefined }> => {
325
+ if (since !== undefined) {
326
+ const commits = await fetchCommitsAfter(apiUrl, since, credential);
327
+ return { commits, olderCommitId: undefined };
328
+ }
329
+ const { commits, olderCommitId } = await fetchCommitsBefore(
330
+ apiUrl,
331
+ { before, all },
332
+ credential
333
+ );
334
+ // 新しい順の応答を時系列に戻す。同一行への連続updateの畳み方が順序に依存する
335
+ commits.reverse();
336
+ return { commits, olderCommitId };
337
+ };
338
+
339
+ const describeRange = (
340
+ { since, before, all }: Pick<ParsedArgs, 'since' | 'before' | 'all'>,
341
+ count: number
342
+ ): string => {
343
+ if (since !== undefined) return `commit ${since} より後`;
344
+ if (all && before !== undefined) return `commit ${before} より前の全履歴`;
345
+ if (all) return '全履歴';
346
+ if (before !== undefined) return `commit ${before} より前 ${count}件`;
347
+ return `最新 ${count}件`;
348
+ };
349
+
246
350
  export const browsePageChanges = async (args: string[]): Promise<void> => {
247
- const { projectUrl, pageId, since } = parseArgs(args);
351
+ const { projectUrl, pageId, since, before, all } = parseArgs(args);
248
352
  const { origin, projectName } = parseProjectUrlStrict(projectUrl);
249
353
 
250
354
  const apiUrl = `${origin}/api/commits/${projectName}/${pageId}`;
251
355
  const credential = resolveCredential(origin, projectName);
252
356
 
253
- const [commits, userMap] = await Promise.all([
254
- fetchAllCommits(apiUrl, since, credential),
357
+ const [{ commits, olderCommitId }, userMap] = await Promise.all([
358
+ fetchCommits(apiUrl, { since, before, all }, credential),
255
359
  fetchUserMap(origin, projectName)
256
360
  ]);
257
361
 
@@ -264,12 +368,18 @@ export const browsePageChanges = async (args: string[]): Promise<void> => {
264
368
 
265
369
  const meta: string[] = [
266
370
  `- pageId: ${pageId}`,
267
- `- 範囲: ${since ? `commit ${since} より後` : '全履歴'}`,
371
+ `- 範囲: ${describeRange({ since, before, all }, commits.length)}`,
268
372
  `- commit数: ${commits.length}`,
269
373
  `- title change: ${latestTitle !== undefined ? quote(latestTitle) : 'なし'}`
270
374
  ];
375
+ if (olderCommitId !== undefined) {
376
+ meta.push(
377
+ `- さらに前の変更を読むコマンド: cosense browsePageChanges ${origin}/${projectName} ${pageId} --before ${olderCommitId}`
378
+ );
379
+ }
271
380
  // タイトルが変わっていればURLも変わっている。新URLを添える
272
- if (latestTitle !== undefined) {
381
+ // --before 付きの範囲のタイトルは当時の物で、その後に変わっている事があるのでURLにしない
382
+ if (latestTitle !== undefined && before === undefined) {
273
383
  const newUrl = `${origin}/${projectName}/${encodeTitleForUrl(latestTitle)}`;
274
384
  meta.push(`- 新URL: ${newUrl}`);
275
385
  const newReadableLink = `${origin}/${projectName}/${encodeTitleForReadableLink(latestTitle)}`;