@helpfeel/cosense-cli 1.17.0 → 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.0",
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",
@@ -27,10 +27,10 @@
27
27
  "tsx": "4.23.15"
28
28
  },
29
29
  "devDependencies": {
30
- "@types/node": "24.19.1",
30
+ "@types/node": "24.19.2",
31
31
  "npm-run-all": "4.1.5",
32
- "oxfmt": "0.71.0",
33
- "oxlint": "1.86.0",
32
+ "oxfmt": "0.73.0",
33
+ "oxlint": "1.88.0",
34
34
  "typescript": "7.0.2"
35
35
  },
36
36
  "engines": {
@@ -10,23 +10,28 @@ import {
10
10
  serviceAccountSuffix,
11
11
  type UserMap
12
12
  } from '../lib/resolveUsers.ts';
13
- import { resolveCredential } from '../lib/settings.ts';
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件にまとめ、変更前→最終のテキストを示す。
@@ -58,6 +66,7 @@ interface Change {
58
66
 
59
67
  interface Commit {
60
68
  id?: string;
69
+ parentId?: string | null;
61
70
  changes?: Change[];
62
71
  userId?: string;
63
72
  created?: number;
@@ -65,28 +74,39 @@ interface Commit {
65
74
 
66
75
  interface CommitsResponse {
67
76
  commits?: Commit[];
77
+ hasMore?: boolean;
68
78
  }
69
79
 
70
80
  interface ParsedArgs {
71
81
  projectUrl: string;
72
82
  pageId: string;
73
83
  since: string | undefined;
84
+ before: string | undefined;
85
+ all: boolean;
74
86
  }
75
87
 
76
88
  const parseArgs = (args: string[]): ParsedArgs => {
77
89
  const usage =
78
- 'Usage: cosense browsePageChanges <projectUrl> <pageId> [--since <commitId>]';
90
+ 'Usage: cosense browsePageChanges <projectUrl> <pageId> [--since <commitId> | [--before <commitId>] [--all]]';
79
91
  let since: string | undefined;
92
+ let before: string | undefined;
93
+ let all = false;
80
94
  const positional: string[] = [];
81
95
  for (let i = 0; i < args.length; i += 1) {
82
96
  const arg = args[i] as string;
83
- if (arg === '--since') {
97
+ if (arg === '--since' || arg === '--before') {
84
98
  const value = args[i + 1];
85
99
  if (value === undefined || value.startsWith('--')) {
86
- 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;
87
106
  }
88
- since = value;
89
107
  i += 1;
108
+ } else if (arg === '--all') {
109
+ all = true;
90
110
  } else if (arg.startsWith('--')) {
91
111
  throw new Error(`Unknown option: ${arg}\n${usage}`);
92
112
  } else {
@@ -96,10 +116,15 @@ const parseArgs = (args: string[]): ParsedArgs => {
96
116
  if (positional.length !== 2) {
97
117
  throw new Error(usage);
98
118
  }
119
+ if (since !== undefined && (before !== undefined || all)) {
120
+ throw new Error(`--since cannot be used with --before or --all\n${usage}`);
121
+ }
99
122
  return {
100
123
  projectUrl: positional[0] as string,
101
124
  pageId: positional[1] as string,
102
- since
125
+ since,
126
+ before,
127
+ all
103
128
  };
104
129
  };
105
130
 
@@ -116,6 +141,97 @@ const resolveUserName = (
116
141
 
117
142
  const quote = (text: string | undefined): string => `「${text ?? ''}」`;
118
143
 
144
+ // 1回の応答は件数と大きさで打ち切られ、続きがある時はhasMoreが真になる
145
+ // headより後のcommitを古い順に、headを最後のcommitに付け替えて全部取る
146
+ const fetchCommitsAfter = async (
147
+ apiUrl: string,
148
+ since: string,
149
+ credential: Credential | undefined
150
+ ): Promise<Commit[]> => {
151
+ const commits: Commit[] = [];
152
+ let head = since;
153
+ let continuing = false;
154
+ const requestedHeads = new Set<string>([since]);
155
+ while (true) {
156
+ const url = `${apiUrl}?head=${encodeURIComponent(head)}`;
157
+ const data = (await requestJson(url, { credential })) as CommitsResponse;
158
+ const batch = data.commits ?? [];
159
+ const first = batch[0];
160
+ // 続きが空だと、取れた分を完全な結果として出してしまうので止める。hasMoreは次の要求が空でない事まで保証しない
161
+ if (continuing && !first) {
162
+ throw new Error(`no commits follow head "${head}"`);
163
+ }
164
+ // headに続かない応答を辿ると、同じ応答を取り続けて終わらないので止める
165
+ if (first && first.parentId !== head) {
166
+ throw new Error(`commits do not follow head "${head}"`);
167
+ }
168
+ // spreadで足すと、1回の応答が大きい時に引数の上限を超える
169
+ for (const commit of batch) commits.push(commit);
170
+ if (!data.hasMore) return commits;
171
+ const next = batch.at(-1)?.id;
172
+ if (!next) {
173
+ throw new Error('hasMore is true but the last commit has no id');
174
+ }
175
+ // 親子が循環した応答はparentIdの確認を通るので、同じheadを2度要求しない事で止める
176
+ if (requestedHeads.has(next)) {
177
+ throw new Error(`commit "${next}" appears again as head`);
178
+ }
179
+ requestedHeads.add(next);
180
+ head = next;
181
+ continuing = true;
182
+ }
183
+ };
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
+
119
235
  // 時系列の変更イベント。同一行への連続updateは1件に畳む
120
236
  type ChangeEvent =
121
237
  | { kind: 'title'; userIds: string[]; created?: number; title: string }
@@ -201,22 +317,48 @@ const renderEvent = (event: ChangeEvent, userMap: UserMap): string => {
201
317
  }
202
318
  };
203
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
+
204
350
  export const browsePageChanges = async (args: string[]): Promise<void> => {
205
- const { projectUrl, pageId, since } = parseArgs(args);
351
+ const { projectUrl, pageId, since, before, all } = parseArgs(args);
206
352
  const { origin, projectName } = parseProjectUrlStrict(projectUrl);
207
353
 
208
- let apiUrl = `${origin}/api/commits/${projectName}/${pageId}`;
209
- if (since) {
210
- apiUrl += `?head=${encodeURIComponent(since)}`;
211
- }
354
+ const apiUrl = `${origin}/api/commits/${projectName}/${pageId}`;
212
355
  const credential = resolveCredential(origin, projectName);
213
356
 
214
- const [data, userMap] = await Promise.all([
215
- requestJson(apiUrl, { credential }) as Promise<CommitsResponse>,
357
+ const [{ commits, olderCommitId }, userMap] = await Promise.all([
358
+ fetchCommits(apiUrl, { since, before, all }, credential),
216
359
  fetchUserMap(origin, projectName)
217
360
  ]);
218
361
 
219
- const commits = data.commits ?? [];
220
362
  const events = buildEvents(commits);
221
363
 
222
364
  const titleEvents = events.filter(e => e.kind === 'title');
@@ -226,12 +368,18 @@ export const browsePageChanges = async (args: string[]): Promise<void> => {
226
368
 
227
369
  const meta: string[] = [
228
370
  `- pageId: ${pageId}`,
229
- `- 範囲: ${since ? `commit ${since} より後` : '全履歴'}`,
371
+ `- 範囲: ${describeRange({ since, before, all }, commits.length)}`,
230
372
  `- commit数: ${commits.length}`,
231
373
  `- title change: ${latestTitle !== undefined ? quote(latestTitle) : 'なし'}`
232
374
  ];
375
+ if (olderCommitId !== undefined) {
376
+ meta.push(
377
+ `- さらに前の変更を読むコマンド: cosense browsePageChanges ${origin}/${projectName} ${pageId} --before ${olderCommitId}`
378
+ );
379
+ }
233
380
  // タイトルが変わっていればURLも変わっている。新URLを添える
234
- if (latestTitle !== undefined) {
381
+ // --before 付きの範囲のタイトルは当時の物で、その後に変わっている事があるのでURLにしない
382
+ if (latestTitle !== undefined && before === undefined) {
235
383
  const newUrl = `${origin}/${projectName}/${encodeTitleForUrl(latestTitle)}`;
236
384
  meta.push(`- 新URL: ${newUrl}`);
237
385
  const newReadableLink = `${origin}/${projectName}/${encodeTitleForReadableLink(latestTitle)}`;