@helpfeel/cosense-cli 1.4.3 → 1.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helpfeel/cosense-cli",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
4
  "description": "Cosense (旧Scrapbox) のページを読み・調べ・編集するAgent Skill用のCLI",
5
5
  "homepage": "https://github.com/helpfeel/cosense-cli",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -7,6 +7,11 @@ import {
7
7
  browsePageHelp,
8
8
  browsePageSummary
9
9
  } from './commands/browsePage.ts';
10
+ import {
11
+ browsePageChanges,
12
+ browsePageChangesHelp,
13
+ browsePageChangesSummary
14
+ } from './commands/browsePageChanges.ts';
10
15
  import {
11
16
  browseRelatedPages,
12
17
  browseRelatedPagesHelp,
@@ -103,6 +108,11 @@ const commands: Record<string, CommandSpec> = {
103
108
  summary: browsePageSummary,
104
109
  help: browsePageHelp
105
110
  },
111
+ browsePageChanges: {
112
+ handler: browsePageChanges,
113
+ summary: browsePageChangesSummary,
114
+ help: browsePageChangesHelp
115
+ },
106
116
  browseRelatedPages: {
107
117
  handler: browseRelatedPages,
108
118
  summary: browseRelatedPagesSummary,
@@ -16,9 +16,9 @@ import {
16
16
  import { resolveCredential } from '../lib/settings.ts';
17
17
 
18
18
  export const browsePageSummary =
19
- '単一ページを読む。メタデータ+アイコン記法+テロメア+本文をAIが読みやすい形式で出力する';
19
+ '単一ページを読む。メタデータ+アイコン記法+テロメア+本文をAIが読みやすい形式で出力する。行permalink (`#<lineId>`) 付きなら該当行をマークする';
20
20
 
21
- export const browsePageHelp = `browsePage - 単一ページを読む。メタデータ+アイコン記法+テロメア+本文をAIが読みやすい形式で出力する
21
+ export const browsePageHelp = `browsePage - 単一ページを読む。メタデータ+アイコン記法+テロメア+本文をAIが読みやすい形式で出力する。行permalink (#<lineId>) 付きなら該当行をマークする
22
22
 
23
23
  Usage:
24
24
  cosense browsePage <pageUrl>
@@ -31,8 +31,11 @@ Usage:
31
31
  # <title>
32
32
 
33
33
  ## メタデータ
34
- タイトル / 作成日時 / 最終更新日時 / 最終アクセス日時 / 被リンク数 / pageRank /
35
- views / snapshot / 行数 / 文字数 / 作成者 / 最終更新者 / 関わったユーザー
34
+ タイトル / pageId / commitId / 作成日時 / 最終更新日時 / 最終アクセス日時 /
35
+ 被リンク数 / pageRank / views / snapshot / 行数 / 文字数 / 作成者 / 最終更新者 /
36
+ 関わったユーザー
37
+ pageId はページの不変ID、commitId は閲覧時点の最新コミット。共同編集中に
38
+ ページを見失ったら、これらを browsePageChanges に渡して変更(リネーム含む)を辿れる
36
39
 
37
40
  ## 人間のアイコン記法
38
41
  本文中の [name.icon] のうち、現メンバーまたは退去済みメンバー (memberSnapshots) の
@@ -79,6 +82,8 @@ interface UserRef {
79
82
  }
80
83
 
81
84
  interface PageData {
85
+ id?: string;
86
+ commitId?: string;
82
87
  title?: string;
83
88
  persistent?: boolean;
84
89
  pageRank?: number;
@@ -155,6 +160,10 @@ const buildTelomere = (lines: PageLine[]): TelomereEntry[] => {
155
160
  const renderMetadata = (page: PageData): string => {
156
161
  const items: string[] = [];
157
162
  if (typeof page.title === 'string') items.push(`- タイトル: ${page.title}`);
163
+ if (typeof page.id === 'string') items.push(`- pageId: ${page.id}`);
164
+ if (typeof page.commitId === 'string') {
165
+ items.push(`- commitId: ${page.commitId}`);
166
+ }
158
167
  if (page.created) items.push(`- 作成日時: ${page.created}`);
159
168
  if (page.updated) items.push(`- 最終更新日時: ${page.updated}`);
160
169
  if (page.accessed) items.push(`- 最終アクセス日時: ${page.accessed}`);
@@ -0,0 +1,239 @@
1
+ import { encodeTitleForUrl } from '../lib/encodeTitle.ts';
2
+ import { formatTimestamp } from '../lib/formatTimestamp.ts';
3
+ import { parseProjectUrlStrict } from '../lib/parseUrl.ts';
4
+ import { requestJson } from '../lib/request.ts';
5
+ import { fetchUserMap, type UserMap } from '../lib/resolveUsers.ts';
6
+ import { resolveCredential } from '../lib/settings.ts';
7
+
8
+ export const browsePageChangesSummary =
9
+ 'ページの編集履歴(commit)をpageId起点で取得し、誰がいつ何をどう変えたかを自然言語で説明する。タイトル変更(リネーム)も検出する';
10
+
11
+ export const browsePageChangesHelp = `browsePageChanges - ページの編集履歴(commit)をpageId起点で取得し自然言語で説明する
12
+
13
+ Usage:
14
+ cosense browsePageChanges <projectUrl> <pageId> [--since <commitId>]
15
+
16
+ 引数:
17
+ <projectUrl> プロジェクトのURL(例: https://scrapbox.io/shokai)
18
+ <pageId> ページの不変ID。browsePage / readPage の出力に含まれる
19
+ --since <commitId> 指定した commitId より後の変更だけを対象にする(カーソル)。
20
+ browsePage / readPage 出力の commitId を控えておき、それを渡すと
21
+ 「前回読んだ後に何が変わったか」だけを取得できる。
22
+ 省略すると全履歴を対象にする。
23
+
24
+ なぜpageId起点か:
25
+ ページタイトルはURLになるため、共同編集者がタイトルを変更すると旧タイトルでは読めなくなる。
26
+ ページの不変IDである pageId から編集履歴を辿れば、リネームされても変更を追える。
27
+
28
+ 出力形式(Markdown plain text。JSONではない):
29
+ # ページ変更履歴
30
+ pageId / 範囲(--since より後か全履歴か)/ commit数 / title change(変更後タイトル、無ければ なし)
31
+ タイトルが変わっていた時のみ、新URLも出力する
32
+ ## 変更
33
+ 時系列の変更イベントを「<日時>\t<誰>が<何を>」の形(TAB区切り)で列挙する。
34
+ 同一行への連続した編集は1件にまとめ、変更前→最終のテキストを示す。
35
+ 本文編集に伴う派生メタデータ(links / icons / linesCount 等)は出力しない。
36
+ `;
37
+
38
+ interface CommitLine {
39
+ id?: string;
40
+ text?: string;
41
+ origText?: string;
42
+ }
43
+
44
+ interface Change {
45
+ title?: string;
46
+ _insert?: string;
47
+ _update?: string;
48
+ _delete?: string;
49
+ lines?: CommitLine;
50
+ }
51
+
52
+ interface Commit {
53
+ id?: string;
54
+ changes?: Change[];
55
+ userId?: string;
56
+ created?: number;
57
+ }
58
+
59
+ interface CommitsResponse {
60
+ commits?: Commit[];
61
+ }
62
+
63
+ interface ParsedArgs {
64
+ projectUrl: string;
65
+ pageId: string;
66
+ since: string | undefined;
67
+ }
68
+
69
+ const parseArgs = (args: string[]): ParsedArgs => {
70
+ const usage =
71
+ 'Usage: cosense browsePageChanges <projectUrl> <pageId> [--since <commitId>]';
72
+ let since: string | undefined;
73
+ const positional: string[] = [];
74
+ for (let i = 0; i < args.length; i += 1) {
75
+ const arg = args[i] as string;
76
+ if (arg === '--since') {
77
+ const value = args[i + 1];
78
+ if (value === undefined || value.startsWith('--')) {
79
+ throw new Error(`--since requires a commitId\n${usage}`);
80
+ }
81
+ since = value;
82
+ i += 1;
83
+ } else if (arg.startsWith('--')) {
84
+ throw new Error(`Unknown option: ${arg}\n${usage}`);
85
+ } else {
86
+ positional.push(arg);
87
+ }
88
+ }
89
+ if (positional.length !== 2) {
90
+ throw new Error(usage);
91
+ }
92
+ return {
93
+ projectUrl: positional[0] as string,
94
+ pageId: positional[1] as string,
95
+ since
96
+ };
97
+ };
98
+
99
+ const resolveUserName = (
100
+ userId: string | undefined,
101
+ userMap: UserMap
102
+ ): string => {
103
+ if (!userId) return '不明なユーザー';
104
+ const info = userMap.get(userId);
105
+ return info?.displayName ?? info?.name ?? userId;
106
+ };
107
+
108
+ const quote = (text: string | undefined): string => `「${text ?? ''}」`;
109
+
110
+ // 時系列の変更イベント。同一行への連続updateは1件に畳む
111
+ type ChangeEvent =
112
+ | { kind: 'title'; userIds: string[]; created?: number; title: string }
113
+ | { kind: 'insert'; userIds: string[]; created?: number; text: string }
114
+ | {
115
+ kind: 'update';
116
+ userIds: string[];
117
+ created?: number;
118
+ lineId: string;
119
+ origText: string;
120
+ text: string;
121
+ }
122
+ | { kind: 'delete'; userIds: string[]; created?: number; origText: string };
123
+
124
+ const buildEvents = (commits: Commit[]): ChangeEvent[] => {
125
+ const events: ChangeEvent[] = [];
126
+ for (const commit of commits) {
127
+ const userId = commit.userId ?? '';
128
+ const created = commit.created;
129
+ for (const change of commit.changes ?? []) {
130
+ if (typeof change.title === 'string') {
131
+ events.push({
132
+ kind: 'title',
133
+ userIds: [userId],
134
+ created,
135
+ title: change.title
136
+ });
137
+ } else if (typeof change._insert === 'string') {
138
+ events.push({
139
+ kind: 'insert',
140
+ userIds: [userId],
141
+ created,
142
+ text: change.lines?.text ?? ''
143
+ });
144
+ } else if (typeof change._update === 'string') {
145
+ const lineId = change._update;
146
+ const last = events[events.length - 1];
147
+ // 同一行への連続したupdateを畳む。origTextは最初の値を保ち、textを最新で上書き
148
+ if (last && last.kind === 'update' && last.lineId === lineId) {
149
+ last.text = change.lines?.text ?? '';
150
+ last.created = created;
151
+ if (userId && !last.userIds.includes(userId)) {
152
+ last.userIds.push(userId);
153
+ }
154
+ } else {
155
+ events.push({
156
+ kind: 'update',
157
+ userIds: [userId],
158
+ created,
159
+ lineId,
160
+ origText: change.lines?.origText ?? '',
161
+ text: change.lines?.text ?? ''
162
+ });
163
+ }
164
+ } else if (typeof change._delete === 'string') {
165
+ events.push({
166
+ kind: 'delete',
167
+ userIds: [userId],
168
+ created,
169
+ origText: change.lines?.origText ?? ''
170
+ });
171
+ }
172
+ // links / icons / projectLinks / descriptions / linesCount / charsCount /
173
+ // helpfeels / infobox* などは本文編集に伴う派生メタなので無視する
174
+ }
175
+ }
176
+ return events;
177
+ };
178
+
179
+ const renderEvent = (event: ChangeEvent, userMap: UserMap): string => {
180
+ const who = event.userIds.map(id => resolveUserName(id, userMap)).join('、');
181
+ const when = formatTimestamp(event.created);
182
+ const prefix = when ? `${when}\t` : '';
183
+ switch (event.kind) {
184
+ case 'title':
185
+ return `${prefix}${who}がタイトルを${quote(event.title)}に変更`;
186
+ case 'insert':
187
+ return `${prefix}${who}が行を追加 ${quote(event.text)}`;
188
+ case 'update':
189
+ return `${prefix}${who}が行を編集 ${quote(event.origText)}→${quote(event.text)}`;
190
+ case 'delete':
191
+ return `${prefix}${who}が行を削除 ${quote(event.origText)}`;
192
+ }
193
+ };
194
+
195
+ export const browsePageChanges = async (args: string[]): Promise<void> => {
196
+ const { projectUrl, pageId, since } = parseArgs(args);
197
+ const { origin, projectName } = parseProjectUrlStrict(projectUrl);
198
+
199
+ let apiUrl = `${origin}/api/commits/${projectName}/${pageId}`;
200
+ if (since) {
201
+ apiUrl += `?head=${encodeURIComponent(since)}`;
202
+ }
203
+ const credential = resolveCredential(origin, projectName);
204
+
205
+ const [data, userMap] = await Promise.all([
206
+ requestJson(apiUrl, { credential }) as Promise<CommitsResponse>,
207
+ fetchUserMap(origin, projectName)
208
+ ]);
209
+
210
+ const commits = data.commits ?? [];
211
+ const events = buildEvents(commits);
212
+
213
+ const titleEvents = events.filter(e => e.kind === 'title');
214
+ const latestTitle = titleEvents.at(-1)?.title;
215
+
216
+ const sections: string[] = ['# ページ変更履歴'];
217
+
218
+ const meta: string[] = [
219
+ `- pageId: ${pageId}`,
220
+ `- 範囲: ${since ? `commit ${since} より後` : '全履歴'}`,
221
+ `- commit数: ${commits.length}`,
222
+ `- title change: ${latestTitle !== undefined ? quote(latestTitle) : 'なし'}`
223
+ ];
224
+ // タイトルが変わっていればURLも変わっている。新URLを添える
225
+ if (latestTitle !== undefined) {
226
+ const newUrl = `${origin}/${projectName}/${encodeTitleForUrl(latestTitle)}`;
227
+ meta.push(`- 新URL: ${newUrl}`);
228
+ }
229
+ sections.push(meta.join('\n'));
230
+
231
+ if (events.length === 0) {
232
+ sections.push('## 変更\n\nこの範囲に説明できる変更はありません');
233
+ } else {
234
+ const lines = events.map(event => renderEvent(event, userMap));
235
+ sections.push(`## 変更\n\n${lines.join('\n')}`);
236
+ }
237
+
238
+ process.stdout.write(`${sections.join('\n\n')}\n`);
239
+ };