@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.
|
|
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.
|
|
33
|
-
"oxlint": "1.
|
|
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 近傍ページタイトル一覧。
|
|
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 = (
|
|
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
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = (
|