@helpfeel/cosense-cli 1.7.0 → 1.9.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.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Cosense (旧Scrapbox) のページを読み・調べ・編集するAgent Skill用のCLI",
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.13.2",
31
31
  "npm-run-all": "4.1.5",
32
- "oxfmt": "0.54.0",
33
- "oxlint": "1.70.0",
32
+ "oxfmt": "0.56.0",
33
+ "oxlint": "1.71.0",
34
34
  "typescript": "6.0.3"
35
35
  },
36
36
  "engines": {
@@ -1,4 +1,5 @@
1
1
  import { enrichTimestampsOf } from '../lib/enrichTimestamps.ts';
2
+ import { renderLiterateDatabase } from '../lib/literateDatabase.ts';
2
3
  import { parsePageUrl } from '../lib/parseUrl.ts';
3
4
  import { fetchRelatedPages } from '../lib/relatedPages.ts';
4
5
  import {
@@ -60,7 +61,11 @@ Usage:
60
61
  本文と関連ページ一覧の境界を示す非Markdown区切り線。Cosenseの#hashtag記法と
61
62
  衝突しないようにMarkdown見出しを避ける。
62
63
  ## 1 hop link
63
- このページの 1-hop 近傍ページタイトル一覧。 1-hop が 0 件なら区切り線ごと省略
64
+ このページの 1-hop 近傍ページタイトル一覧。 0 件なら区切り線ごと省略。
65
+ このページがinfobox定義ページ(本文に table:infobox または table:cosense を宣言している)の
66
+ 時は ## 1 hop link(Infoboxの文芸的データベース、TSV形式のテーブル) に変わる。表の構造は
67
+ cosense browseRelatedPages --help を参照。表に載らなかったページは
68
+ ## 1 hop link(表に載っていないページ) に出力する
64
69
 
65
70
  persistent: false の時:
66
71
  メタデータ・アイコン・テロメア・Infoboxは省略。 (このページはまだ作成されていません) と
@@ -119,6 +124,7 @@ interface PageData {
119
124
  lines?: PageLine[];
120
125
  icons?: string[];
121
126
  infoboxResult?: InfoboxResult[];
127
+ infoboxDefinition?: string[];
122
128
  }
123
129
 
124
130
  const LINE_ID_PATTERN = /^[0-9a-f]{24}$/;
@@ -283,11 +289,26 @@ const renderBody = (lines: PageLine[], fragment: string | null): BodyRender => {
283
289
  return { body: out.join('\n'), matchedFragment };
284
290
  };
285
291
 
286
- const renderRelatedPages = (hopValue: unknown): string | null => {
292
+ const renderRelatedPages = (
293
+ hopValue: unknown,
294
+ page: PageData
295
+ ): string | null => {
296
+ const sections: string[] = [];
297
+ // seen は文芸的データベースと 1 hop link で共有して、表に載ったページを再掲しないようにする
298
+ const seen = new Set<string>();
299
+ const literateDatabase = renderLiterateDatabase(page, hopValue, seen);
300
+ if (literateDatabase) sections.push(literateDatabase);
301
+
287
302
  const links1hop = (hopValue as { links1hop?: RelatedPage[] }).links1hop;
288
- const pages = dedupAndSortByPageRank(links1hop);
289
- if (pages.length === 0) return null;
290
- return `-------------------- Related Pages --------------------\n\n## 1 hop link\n\n${renderGroups(buildGroups(pages))}`;
303
+ const pages = dedupAndSortByPageRank(links1hop, seen);
304
+ if (pages.length > 0) {
305
+ const heading = literateDatabase
306
+ ? '## 1 hop link(表に載っていないページ)'
307
+ : '## 1 hop link';
308
+ sections.push(`${heading}\n\n${renderGroups(buildGroups(pages))}`);
309
+ }
310
+ if (sections.length === 0) return null;
311
+ return `-------------------- Related Pages --------------------\n\n${sections.join('\n\n')}`;
291
312
  };
292
313
 
293
314
  export const browsePage = async (args: string[]): Promise<void> => {
@@ -317,7 +338,7 @@ export const browsePage = async (args: string[]): Promise<void> => {
317
338
  sections.push('(このページはまだ作成されていません)');
318
339
  const { body } = renderBody(page.lines ?? [], null);
319
340
  sections.push(`## 本文(テンプレート)\n\n${body}`);
320
- const related = renderRelatedPages(hopValue);
341
+ const related = renderRelatedPages(hopValue, page);
321
342
  if (related) sections.push(related);
322
343
  process.stdout.write(`${sections.join('\n\n')}\n`);
323
344
  return;
@@ -368,7 +389,7 @@ export const browsePage = async (args: string[]): Promise<void> => {
368
389
 
369
390
  sections.push(`## 本文\n\n${body}`);
370
391
 
371
- const related = renderRelatedPages(hopValue);
392
+ const related = renderRelatedPages(hopValue, page);
372
393
  if (related) sections.push(related);
373
394
 
374
395
  process.stdout.write(`${sections.join('\n\n')}\n`);
@@ -4,12 +4,19 @@ import {
4
4
  type Page,
5
5
  renderGroups
6
6
  } from '../lib/relatedPagesFormat.ts';
7
+ import {
8
+ type DefinitionPage,
9
+ renderLiterateDatabase
10
+ } from '../lib/literateDatabase.ts';
7
11
  import { fetchRelatedPages } from '../lib/relatedPages.ts';
12
+ import { parsePageUrl } from '../lib/parseUrl.ts';
13
+ import { requestJson } from '../lib/request.ts';
14
+ import { resolveCredential } from '../lib/settings.ts';
8
15
 
9
16
  export const browseRelatedPagesSummary =
10
- '1-hop+2-hopの関連ページタイトル一覧をAIが読みやすい形式で出力する';
17
+ '1-hop+2-hopの関連ページタイトル一覧をAIが読みやすい形式で出力する。infobox定義ページでは文芸的データベース(TSV表)を出力する';
11
18
 
12
- export const browseRelatedPagesHelp = `browseRelatedPages - 1-hop+2-hopの関連ページタイトル一覧をAIが読みやすい形式で出力する
19
+ export const browseRelatedPagesHelp = `browseRelatedPages - 1-hop+2-hopの関連ページタイトル一覧をAIが読みやすい形式で出力する。infobox定義ページでは文芸的データベース(TSV表)を出力する
13
20
 
14
21
  Usage:
15
22
  cosense browseRelatedPages <pageUrl>
@@ -32,6 +39,26 @@ Usage:
32
39
  ## 2 hop link
33
40
 
34
41
  - タイトル
42
+
43
+ 対象がinfobox定義ページ(本文に table:infobox または table:cosense を宣言している)の時:
44
+ Web UIと同様に、関連ページリストの先頭が文芸的データベース(TSV形式のテーブル)になる。
45
+ 行 = このページにリンクしているページ(pageRank 降順)。
46
+ 列 = Page / Created / Updated + 定義された項目。
47
+ セル = 各ページの本文からInfoboxが抜き出した値(Cosense記法のまま。セル内の改行は「 / 」に置換)。
48
+ 表に載らなかった関連ページは、続く ## 1 hop link(表に載っていないページ) に出力する。
49
+
50
+ # Related Pages
51
+
52
+ ## 1 hop link(Infoboxの文芸的データベース、TSV形式のテーブル)
53
+
54
+ Page/Created/Updated以外の列は、各ページの本文からInfoboxが抜き出した値
55
+
56
+ Page Created Updated 材料 カテゴリ
57
+ 麻婆豆腐 2018-03-04 2026-07-05 [豆腐]、[挽き肉] 中華
58
+
59
+ ## 1 hop link(表に載っていないページ)
60
+
61
+ - 料理
35
62
  `;
36
63
 
37
64
  export const browseRelatedPages = async (args: string[]): Promise<void> => {
@@ -39,8 +66,15 @@ export const browseRelatedPages = async (args: string[]): Promise<void> => {
39
66
  throw new Error('Usage: cosense browseRelatedPages <pageUrl>');
40
67
  }
41
68
  const [url] = args as [string];
69
+ const { origin, projectName, encodedTitle } = parsePageUrl(url);
70
+ const credential = resolveCredential(origin, projectName);
42
71
 
43
- const [result1hop, result2hop] = await Promise.allSettled([
72
+ // ページ本体はinfobox定義の取得にだけ使う。失敗しても従来のタイトル一覧に
73
+ // フォールバックできるよう、関連ページの取得失敗とは区別する
74
+ const [resultPage, result1hop, result2hop] = await Promise.allSettled([
75
+ requestJson(`${origin}/api/pages/v2/${projectName}/${encodedTitle}`, {
76
+ credential
77
+ }) as Promise<DefinitionPage>,
44
78
  fetchRelatedPages(url, 1),
45
79
  fetchRelatedPages(url, 2)
46
80
  ]);
@@ -49,8 +83,35 @@ export const browseRelatedPages = async (args: string[]): Promise<void> => {
49
83
  throw result1hop.reason;
50
84
  }
51
85
 
52
- // seen 1-hop と 2-hop で共有して、 1-hop に出たページが 2-hop にも再掲されないようにする
86
+ const sections: string[] = [];
87
+ // seen は文芸的データベース・1-hop・2-hopで共有して、先に出たページを再掲しないようにする
53
88
  const seen = new Set<string>();
89
+
90
+ let hasLiterateDatabase = false;
91
+ if (resultPage.status === 'rejected') {
92
+ // 非定義ページと区別が付かないまま黙って通常出力に落ちると、AIが不完全な
93
+ // 出力を信じてしまうので、判定不能である事をstderrで知らせる
94
+ process.stderr.write(
95
+ 'ページ情報の取得に失敗したため、infobox定義ページかどうか判定できません。文芸的データベースがあっても出力されません\n'
96
+ );
97
+ } else if (result1hop.status === 'fulfilled') {
98
+ const literateDatabase = renderLiterateDatabase(
99
+ resultPage.value,
100
+ result1hop.value,
101
+ seen
102
+ );
103
+ if (literateDatabase) {
104
+ sections.push(literateDatabase);
105
+ hasLiterateDatabase = true;
106
+ }
107
+ } else if ((resultPage.value.infoboxDefinition?.length ?? 0) > 0) {
108
+ // infobox定義ページだと確定しているのに関連ページの取得に失敗した場合も、
109
+ // 表が黙って欠落しないように知らせる
110
+ process.stderr.write(
111
+ '関連ページの取得に失敗したため、このinfobox定義ページの文芸的データベースを出力できません\n'
112
+ );
113
+ }
114
+
54
115
  const pages1hop =
55
116
  result1hop.status === 'fulfilled'
56
117
  ? dedupAndSortByPageRank(
@@ -66,9 +127,11 @@ export const browseRelatedPages = async (args: string[]): Promise<void> => {
66
127
  )
67
128
  : [];
68
129
 
69
- const sections: string[] = [];
70
130
  if (pages1hop.length > 0) {
71
- sections.push(`## 1 hop link\n\n${renderGroups(buildGroups(pages1hop))}`);
131
+ const heading = hasLiterateDatabase
132
+ ? '## 1 hop link(表に載っていないページ)'
133
+ : '## 1 hop link';
134
+ sections.push(`${heading}\n\n${renderGroups(buildGroups(pages1hop))}`);
72
135
  }
73
136
  if (pages2hop.length > 0) {
74
137
  sections.push(`## 2 hop link\n\n${renderGroups(buildGroups(pages2hop))}`);
@@ -0,0 +1,133 @@
1
+ import { type Page, toTitleLc } from './relatedPagesFormat.ts';
2
+
3
+ interface InfoboxResultEntry {
4
+ title?: string;
5
+ infobox?: Record<string, string>;
6
+ }
7
+
8
+ export interface LiterateDatabaseSourcePage extends Page {
9
+ linksLc?: string[];
10
+ created?: number;
11
+ updated?: number;
12
+ infoboxResult?: InfoboxResultEntry[];
13
+ infoboxDisableLinks?: string[];
14
+ }
15
+
16
+ interface Links1hopResponse {
17
+ links1hop?: LiterateDatabaseSourcePage[];
18
+ pagination?: { total?: number; hasNext?: boolean };
19
+ }
20
+
21
+ export interface DefinitionPage {
22
+ title?: string;
23
+ infoboxDefinition?: string[];
24
+ }
25
+
26
+ // 定義行はタブ区切りで、第1セルが列名。第2セル以降はInfoboxが値を抜き出す時の
27
+ // 指示文なので列にしない。第1セルがオプション宣言 (ExcludeTitleLine) の行も列にしない。
28
+ // 列名をtrimしないのは意図的: infoboxResultのkeyは未trimの第1セルで生成されるため、
29
+ // trimするとlookupが外れる(オプション宣言の判定だけがtrim込みで行われる)
30
+ const parseInfoboxFieldNames = (infoboxDefinition: string[]): string[] => {
31
+ const fieldNames: string[] = [];
32
+ for (const row of infoboxDefinition) {
33
+ const firstCell = row.split('\t')[0] ?? '';
34
+ if (firstCell.trim() === 'ExcludeTitleLine') continue;
35
+ fieldNames.push(firstCell);
36
+ }
37
+ return fieldNames;
38
+ };
39
+
40
+ interface LiterateDatabaseRow {
41
+ page: LiterateDatabaseSourcePage;
42
+ infobox: Record<string, string>;
43
+ }
44
+
45
+ // 行選定はWeb UIの文芸的データベースと同じ:
46
+ // 定義ページにリンクしているページのうち、無効化されていないもの。
47
+ // 並びは通常の関連ページリストとソート基準を揃えたpageRank降順(Web UIとは異なる)
48
+ const buildRows = (
49
+ definition: DefinitionPage,
50
+ links1hop: LiterateDatabaseSourcePage[]
51
+ ): LiterateDatabaseRow[] => {
52
+ const definitionTitleLc = toTitleLc(definition.title ?? '');
53
+
54
+ const rows: LiterateDatabaseRow[] = [];
55
+ for (const page of links1hop) {
56
+ const linksLc = page.linksLc ?? [];
57
+ if (!linksLc.includes(definitionTitleLc)) continue;
58
+ if (page.infoboxDisableLinks?.includes(definitionTitleLc)) continue;
59
+
60
+ const infobox =
61
+ page.infoboxResult?.find(
62
+ entry => toTitleLc(entry.title ?? '') === definitionTitleLc
63
+ )?.infobox ?? {};
64
+ rows.push({ page, infobox });
65
+ }
66
+
67
+ rows.sort((a, b) => (b.page.pageRank ?? 0) - (a.page.pageRank ?? 0));
68
+ return rows;
69
+ };
70
+
71
+ // TSVの1ページ=1行を守るため、セル内の改行・タブを置換する。
72
+ // セル値はAIが抽出した物なので、文字列以外が混ざっていても落ちないように文字列化する
73
+ const sanitizeCell = (value: unknown): string =>
74
+ String(value ?? '')
75
+ .replace(/\t/g, ' ')
76
+ .replace(/\r\n|[\r\n]/g, ' / ');
77
+
78
+ const formatDateCell = (unixSec: number | undefined): string => {
79
+ if (typeof unixSec !== 'number') return '';
80
+ const d = new Date(unixSec * 1000);
81
+ const month = String(d.getMonth() + 1).padStart(2, '0');
82
+ const day = String(d.getDate()).padStart(2, '0');
83
+ return `${d.getFullYear()}-${month}-${day}`;
84
+ };
85
+
86
+ // 対象がinfobox定義ページなら文芸的データベースのセクションを組み立てる。
87
+ // 定義ページでなければ null。表に載せたページは seen に積み、呼び出し側の
88
+ // 1 hop link 一覧に再掲されないようにする
89
+ export const renderLiterateDatabase = (
90
+ definition: DefinitionPage,
91
+ hopValue: unknown,
92
+ seen: Set<string>
93
+ ): string | null => {
94
+ const infoboxDefinition = definition.infoboxDefinition ?? [];
95
+ if (infoboxDefinition.length === 0) return null;
96
+
97
+ const { links1hop = [], pagination } = (hopValue ?? {}) as Links1hopResponse;
98
+ const fieldNames = parseInfoboxFieldNames(infoboxDefinition);
99
+ const rows = buildRows(definition, links1hop);
100
+ for (const { page } of rows) {
101
+ seen.add(page.titleLc ?? toTitleLc(page.title));
102
+ }
103
+
104
+ const header = ['Page', 'Created', 'Updated', ...fieldNames].join('\t');
105
+ const lines = rows.map(({ page, infobox }) =>
106
+ [
107
+ sanitizeCell(page.title),
108
+ formatDateCell(page.created),
109
+ formatDateCell(page.updated),
110
+ ...fieldNames.map(field => sanitizeCell(infobox[field] ?? ''))
111
+ ].join('\t')
112
+ );
113
+
114
+ // 見出しは通常ページの「1 hop link」に揃える。「文芸的データベース」を見出し単体で
115
+ // 使うと、出力だけを読むAIが同名ページを探しに行ってしまう。ただしユーザーはこの表を
116
+ // 「文芸的データベース」「Infoboxの表」「テーブル」等とも呼ぶので、指示と結びつくよう
117
+ // 別名として括弧内に残す
118
+ const parts = [
119
+ '## 1 hop link(Infoboxの文芸的データベース、TSV形式のテーブル)',
120
+ 'Page/Created/Updated以外の列は、各ページの本文からInfoboxが抜き出した値',
121
+ [header, ...lines].join('\n')
122
+ ];
123
+ if (rows.length === 0) {
124
+ // ヘッダーだけの表は出力が途切れたようにも見えるので、0件である事を明示する
125
+ parts.push('表に載るページが無いため、行は0件');
126
+ }
127
+ if (pagination?.hasNext) {
128
+ parts.push(
129
+ `注意: 関連ページが多いため、全${pagination.total}件のうち取得できた${links1hop.length}件から表を構成しています`
130
+ );
131
+ }
132
+ return parts.join('\n\n');
133
+ };
@@ -40,7 +40,7 @@ export const buildGroups = (pages: Page[]): Group[] => {
40
40
  return groups;
41
41
  };
42
42
 
43
- const toTitleLc = (title: string): string =>
43
+ export const toTitleLc = (title: string): string =>
44
44
  title.replace(/ /g, '_').toLowerCase();
45
45
 
46
46
  export const dedupAndSortByPageRank = (