sync: update from private repo (7d64ee2)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
c526adddc2
commit
02c7dfdd83
@ -1,4 +1,4 @@
|
|||||||
# X / Twitter ツール(XSearch / XUserPosts / XPostDetail / XFetchCardMedia)
|
# X / Twitter ツール(XSearch / XUserPosts / XPostDetail / XTimeline / XFetchCardMedia)
|
||||||
|
|
||||||
twitter-cli を内部で呼び出して X (旧 Twitter) のデータを取得する read-only ツール群。
|
twitter-cli を内部で呼び出して X (旧 Twitter) のデータを取得する read-only ツール群。
|
||||||
|
|
||||||
@ -37,6 +37,23 @@ XUserPosts({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## XTimeline — ホームタイムライン(ログイン中アカウント)
|
||||||
|
|
||||||
|
```js
|
||||||
|
XTimeline({
|
||||||
|
tab: "for_you", // for_you (デフォルト) / following
|
||||||
|
limit: 20, // デフォルト 20, 最大 50
|
||||||
|
full_text: true,
|
||||||
|
compact: false,
|
||||||
|
output_path: "x/timeline.txt" // 任意
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
設定済みの `auth_token` / `ct0` cookie に紐づくアカウントの**ホームタイムライン**を返す。
|
||||||
|
`tab: "for_you"`(おすすめ)と `tab: "following"`(フォロー中)を切り替えられる。
|
||||||
|
内部的には `twitter feed [-t following]` を呼ぶ。検索ではなく「ログイン中ユーザーが
|
||||||
|
普段見るフィード」を取得したいときに使う。出力・メディア DL の扱いは XSearch と共通。
|
||||||
|
|
||||||
## XPostDetail — 投稿の詳細+リプライ
|
## XPostDetail — 投稿の詳細+リプライ
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
|||||||
@ -66,7 +66,7 @@ movements:
|
|||||||
- `result` がそのままユーザーに表示される最終出力。途中のメモや作業ログは入れない
|
- `result` がそのままユーザーに表示される最終出力。途中のメモや作業ログは入れない
|
||||||
- **ユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "確認したい内容", why_no_default: "デフォルトで進められない理由"})`
|
- **ユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "確認したい内容", why_no_default: "デフォルトで進められない理由"})`
|
||||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "失敗の理由"})`
|
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "失敗の理由"})`
|
||||||
allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadExcel, ReadDocx, ReadPPTX, SQLite, Bash, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, BrowseWeb, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, ListPieces, GetPiece, CreatePiece, UpdatePiece, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, ReadToolDoc, UpdateDashboardWidget, 'mcp__*']
|
allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadExcel, ReadDocx, ReadPPTX, SQLite, Bash, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, ListPieces, GetPiece, CreatePiece, UpdatePiece, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, ReadToolDoc, UpdateDashboardWidget, 'mcp__*']
|
||||||
# default_next is the engine-internal fallback for context overflow / ASK
|
# default_next is the engine-internal fallback for context overflow / ASK
|
||||||
# limit reached / SpawnSubTask unavailable. It is NOT exposed to the LLM.
|
# limit reached / SpawnSubTask unavailable. It is NOT exposed to the LLM.
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
|
|||||||
@ -45,7 +45,7 @@ movements:
|
|||||||
- **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})`
|
- **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})`
|
||||||
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
||||||
default_next: analyze
|
default_next: analyze
|
||||||
rules:
|
rules:
|
||||||
- condition: output/ に情報を書き出した
|
- condition: output/ に情報を書き出した
|
||||||
@ -72,7 +72,7 @@ movements:
|
|||||||
|
|
||||||
output/images/ に画像がある場合は、必ずレポートの該当箇所に埋め込む:
|
output/images/ に画像がある場合は、必ずレポートの該当箇所に埋め込む:
|
||||||
``
|
``
|
||||||
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
||||||
default_next: verify
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: output/ にレポートを書き出した
|
- condition: output/ にレポートを書き出した
|
||||||
|
|||||||
@ -113,7 +113,7 @@ movements:
|
|||||||
- **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})`
|
- **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})`
|
||||||
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
||||||
default_next: analyze
|
default_next: analyze
|
||||||
rules:
|
rules:
|
||||||
- condition: 2つ以上の独立した調査テーマがあり、並列分解が効率的と判断した
|
- condition: 2つ以上の独立した調査テーマがあり、並列分解が効率的と判断した
|
||||||
@ -146,7 +146,7 @@ movements:
|
|||||||
画像があるのにテキストだけのレポートにしないこと。
|
画像があるのにテキストだけのレポートにしないこと。
|
||||||
レポート作成中に追加で必要な図・グラフを見つけた場合も DownloadFile で収集して埋め込む。
|
レポート作成中に追加で必要な図・グラフを見つけた場合も DownloadFile で収集して埋め込む。
|
||||||
|
|
||||||
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
||||||
default_next: verify
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: output/ にレポートを書き出した
|
- condition: output/ にレポートを書き出した
|
||||||
|
|||||||
@ -28,6 +28,7 @@ movements:
|
|||||||
- キーワードで広く拾う → XSearch
|
- キーワードで広く拾う → XSearch
|
||||||
- 特定アカウントの発言を追う → XUserPosts
|
- 特定アカウントの発言を追う → XUserPosts
|
||||||
- 議論の流れ・リプライツリーまで欲しい → XPostDetail
|
- 議論の流れ・リプライツリーまで欲しい → XPostDetail
|
||||||
|
- ログイン中アカウントのホームタイムライン(おすすめ / フォロー中)を見る → XTimeline
|
||||||
|
|
||||||
### Reddit
|
### Reddit
|
||||||
BrowseWeb で必ず **old.reddit.com** を使う(軽量でテキスト抽出しやすい)。
|
BrowseWeb で必ず **old.reddit.com** を使う(軽量でテキスト抽出しやすい)。
|
||||||
@ -56,7 +57,7 @@ movements:
|
|||||||
- **追加収集のため同じ gather を続行**: `transition({next_step: "gather"})`
|
- **追加収集のため同じ gather を続行**: `transition({next_step: "gather"})`
|
||||||
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [XSearch, XUserPosts, XPostDetail, XFetchCardMedia, BrowseWeb, WebFetch, WebSearch, Read, Write, Edit, Glob, Grep, DownloadFile, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, 'mcp__*']
|
allowed_tools: [XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, WebFetch, WebSearch, Read, Write, Edit, Glob, Grep, DownloadFile, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, 'mcp__*']
|
||||||
default_next: analyze
|
default_next: analyze
|
||||||
rules:
|
rules:
|
||||||
- condition: 追加収集が必要(別のSNS、追加クエリ等)
|
- condition: 追加収集が必要(別のSNS、追加クエリ等)
|
||||||
|
|||||||
@ -61,6 +61,7 @@ movements:
|
|||||||
- XSearch
|
- XSearch
|
||||||
- XUserPosts
|
- XUserPosts
|
||||||
- XPostDetail
|
- XPostDetail
|
||||||
|
- XTimeline
|
||||||
- XFetchCardMedia
|
- XFetchCardMedia
|
||||||
- BrowseWeb
|
- BrowseWeb
|
||||||
- WebFetch
|
- WebFetch
|
||||||
|
|||||||
@ -41,6 +41,7 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
|
|||||||
xuserposts: 'xsearch',
|
xuserposts: 'xsearch',
|
||||||
xpostdetail: 'xsearch',
|
xpostdetail: 'xsearch',
|
||||||
xfetchcardmedia: 'xsearch',
|
xfetchcardmedia: 'xsearch',
|
||||||
|
xtimeline: 'xsearch',
|
||||||
// youtube.ts をまとめる
|
// youtube.ts をまとめる
|
||||||
searchyoutube: 'getyoutubetranscript',
|
searchyoutube: 'getyoutubetranscript',
|
||||||
// maps.ts をまとめる
|
// maps.ts をまとめる
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export const RAW_SAVE_TOOLS = new Set([
|
|||||||
'XSearch',
|
'XSearch',
|
||||||
'XUserPosts',
|
'XUserPosts',
|
||||||
'XPostDetail',
|
'XPostDetail',
|
||||||
|
'XTimeline',
|
||||||
'BrowseWeb',
|
'BrowseWeb',
|
||||||
'GetYouTubeTranscript',
|
'GetYouTubeTranscript',
|
||||||
'SearchYouTube',
|
'SearchYouTube',
|
||||||
|
|||||||
@ -126,7 +126,7 @@ function mkStubSubsystem() {
|
|||||||
setWindow: vi.fn(),
|
setWindow: vi.fn(),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
};
|
};
|
||||||
const client = { end: vi.fn() };
|
const client = { end: vi.fn(), on: vi.fn() };
|
||||||
const openShellChannel = vi.fn().mockResolvedValue({
|
const openShellChannel = vi.fn().mockResolvedValue({
|
||||||
channel,
|
channel,
|
||||||
client,
|
client,
|
||||||
|
|||||||
@ -246,6 +246,7 @@ async function ensureSessionInternal(
|
|||||||
|
|
||||||
// Open the channel. On failure clear the PEM and bail.
|
// Open the channel. On failure clear the PEM and bail.
|
||||||
let channel: import('ssh2').ClientChannel;
|
let channel: import('ssh2').ClientChannel;
|
||||||
|
let client: import('ssh2').Client;
|
||||||
let hostFingerprint: string;
|
let hostFingerprint: string;
|
||||||
try {
|
try {
|
||||||
const shellResult = await sub.openShellChannel({
|
const shellResult = await sub.openShellChannel({
|
||||||
@ -266,6 +267,7 @@ async function ensureSessionInternal(
|
|||||||
timeoutMs: sub.config.callTimeoutSeconds * 1000,
|
timeoutMs: sub.config.callTimeoutSeconds * 1000,
|
||||||
});
|
});
|
||||||
channel = shellResult.channel;
|
channel = shellResult.channel;
|
||||||
|
client = shellResult.client;
|
||||||
hostFingerprint = shellResult.hostFingerprint;
|
hostFingerprint = shellResult.hostFingerprint;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
clearBuffer(pemBuf);
|
clearBuffer(pemBuf);
|
||||||
@ -292,8 +294,10 @@ async function ensureSessionInternal(
|
|||||||
return err(`SshConsoleEnsure: failed to open shell channel: ${(e as Error).message}`);
|
return err(`SshConsoleEnsure: failed to open shell channel: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the session and register it. From here on the channel + PEM
|
// Build the session and register it. From here on the channel + client
|
||||||
// belong to the session; we don't clear them on the happy path.
|
// + PEM belong to the session; we don't clear them on the happy path.
|
||||||
|
// The session ends the client (and thus releases the PEM-bound
|
||||||
|
// connection) when it closes.
|
||||||
const session = new ConsoleSession({
|
const session = new ConsoleSession({
|
||||||
localTaskId,
|
localTaskId,
|
||||||
connectionId,
|
connectionId,
|
||||||
@ -303,6 +307,7 @@ async function ensureSessionInternal(
|
|||||||
rows,
|
rows,
|
||||||
scrollbackCap: sub.config.console.scrollbackBytes,
|
scrollbackCap: sub.config.console.scrollbackBytes,
|
||||||
channel,
|
channel,
|
||||||
|
client,
|
||||||
auditRepo: sub.auditRepo,
|
auditRepo: sub.auditRepo,
|
||||||
});
|
});
|
||||||
sub.sessionRegistry.register(session);
|
sub.sessionRegistry.register(session);
|
||||||
|
|||||||
@ -129,11 +129,30 @@ const XFETCHCARDMEDIA_DEF: ToolDef = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const XTIMELINE_DEF: ToolDef = {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'XTimeline',
|
||||||
|
description: 'ログイン中アカウントのホームタイムライン(For You / フォロー中)を取得する(twitter-cli 経由、認証 Cookie 設定が必要)。詳細は ReadToolDoc({ name: "XTimeline" })。',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tab: { type: 'string', enum: ['for_you', 'following'], description: 'for_you (デフォルト) / following' },
|
||||||
|
limit: { type: 'number', description: '件数 (デフォルト: 20, 最大: 50)' },
|
||||||
|
full_text: { type: 'boolean', description: '長文の省略を避ける' },
|
||||||
|
compact: { type: 'boolean', description: 'token 節約向けの compact 出力' },
|
||||||
|
output_path: { type: 'string', description: '任意: output/x/ 配下に保存する相対パス' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||||
XSearch: XSEARCH_DEF,
|
XSearch: XSEARCH_DEF,
|
||||||
XUserPosts: XUSERPOSTS_DEF,
|
XUserPosts: XUSERPOSTS_DEF,
|
||||||
XPostDetail: XPOSTDETAIL_DEF,
|
XPostDetail: XPOSTDETAIL_DEF,
|
||||||
XFetchCardMedia: XFETCHCARDMEDIA_DEF,
|
XFetchCardMedia: XFETCHCARDMEDIA_DEF,
|
||||||
|
XTimeline: XTIMELINE_DEF,
|
||||||
};
|
};
|
||||||
|
|
||||||
type XHistoryRecord = {
|
type XHistoryRecord = {
|
||||||
@ -608,6 +627,36 @@ async function executeXUserPosts(input: Record<string, unknown>, ctx: ToolContex
|
|||||||
return runTwitterCli('XUserPosts', args, ctx, outputPath);
|
return runTwitterCli('XUserPosts', args, ctx, outputPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function executeXTimeline(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||||
|
const tab = String(input['tab'] ?? 'for_you');
|
||||||
|
const following = tab === 'following';
|
||||||
|
const args = ['feed', '--max', String(clampLimit(input['limit'], 20)), '--yaml'];
|
||||||
|
if (following) args.push('-t', 'following');
|
||||||
|
maybePushFlag(args, input['full_text'], '--full-text');
|
||||||
|
maybePushFlag(args, input['compact'], '--compact');
|
||||||
|
let outputPath: string | null;
|
||||||
|
try {
|
||||||
|
outputPath = resolveOptionalOutputPath(ctx, input['output_path']);
|
||||||
|
} catch (err) {
|
||||||
|
return { output: `XTimeline error: ${(err as Error).message}`, isError: true };
|
||||||
|
}
|
||||||
|
const result = await runTwitterCli('XTimeline', args, ctx, outputPath);
|
||||||
|
if (result.isError) return result;
|
||||||
|
|
||||||
|
const posts = parseXPostsFromYaml(result.output);
|
||||||
|
if (posts.length > 0) {
|
||||||
|
const refId = `xposts-${Date.now()}`;
|
||||||
|
const structuredBlocks: StructuredBlock[] = [{
|
||||||
|
refId,
|
||||||
|
type: 'x_posts',
|
||||||
|
title: following ? 'X タイムライン(フォロー中)' : 'X タイムライン(For You)',
|
||||||
|
data: { query: following ? 'following' : 'for_you', posts },
|
||||||
|
}];
|
||||||
|
return { output: `${result.output}\n\n[[embed:${refId}]]`, isError: false, structuredBlocks };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async function executeXPostDetail(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
async function executeXPostDetail(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||||
const tweet = String(input['tweet'] ?? '').trim();
|
const tweet = String(input['tweet'] ?? '').trim();
|
||||||
if (!tweet) return { output: 'XPostDetail error: tweet is required', isError: true };
|
if (!tweet) return { output: 'XPostDetail error: tweet is required', isError: true };
|
||||||
@ -862,6 +911,8 @@ export async function executeTool(
|
|||||||
return executeXSearch(input, ctx);
|
return executeXSearch(input, ctx);
|
||||||
case 'XUserPosts':
|
case 'XUserPosts':
|
||||||
return executeXUserPosts(input, ctx);
|
return executeXUserPosts(input, ctx);
|
||||||
|
case 'XTimeline':
|
||||||
|
return executeXTimeline(input, ctx);
|
||||||
case 'XPostDetail':
|
case 'XPostDetail':
|
||||||
return executeXPostDetail(input, ctx);
|
return executeXPostDetail(input, ctx);
|
||||||
case 'XFetchCardMedia':
|
case 'XFetchCardMedia':
|
||||||
|
|||||||
@ -57,7 +57,7 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray<string> = [
|
|||||||
// orchestration.ts
|
// orchestration.ts
|
||||||
'SpawnSubTask',
|
'SpawnSubTask',
|
||||||
// x.ts
|
// x.ts
|
||||||
'XPostDetail', 'XSearch', 'XUserPosts',
|
'XPostDetail', 'XSearch', 'XTimeline', 'XUserPosts',
|
||||||
// maps.ts
|
// maps.ts
|
||||||
'GetDirections', 'ReverseGeocode', 'SearchPlaces',
|
'GetDirections', 'ReverseGeocode', 'SearchPlaces',
|
||||||
// youtube.ts
|
// youtube.ts
|
||||||
|
|||||||
@ -13,6 +13,11 @@ class StubChannel extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class StubClient extends EventEmitter {
|
||||||
|
ended = false;
|
||||||
|
end(): void { this.ended = true; this.emit('close'); }
|
||||||
|
}
|
||||||
|
|
||||||
function mkAudit() {
|
function mkAudit() {
|
||||||
return {
|
return {
|
||||||
beginAndComplete: vi.fn(),
|
beginAndComplete: vi.fn(),
|
||||||
@ -21,7 +26,7 @@ function mkAudit() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mkSession(channel: StubChannel) {
|
function mkSession(channel: StubChannel, client: StubClient = new StubClient()) {
|
||||||
const audit = mkAudit();
|
const audit = mkAudit();
|
||||||
const session = new ConsoleSession({
|
const session = new ConsoleSession({
|
||||||
localTaskId: 't1',
|
localTaskId: 't1',
|
||||||
@ -32,9 +37,10 @@ function mkSession(channel: StubChannel) {
|
|||||||
rows: 24,
|
rows: 24,
|
||||||
scrollbackCap: 1024,
|
scrollbackCap: 1024,
|
||||||
channel: channel as any,
|
channel: channel as any,
|
||||||
|
client: client as any,
|
||||||
auditRepo: audit as any,
|
auditRepo: audit as any,
|
||||||
});
|
});
|
||||||
return { session, audit };
|
return { session, audit, client };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ConsoleSession', () => {
|
describe('ConsoleSession', () => {
|
||||||
@ -131,18 +137,43 @@ describe('ConsoleSession', () => {
|
|||||||
expect(ch.windowChanges).toEqual([{ rows: 40, cols: 100 }]);
|
expect(ch.windowChanges).toEqual([{ rows: 40, cols: 100 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('close() is idempotent and records audit', async () => {
|
it('close() is idempotent, ends the client, and records audit', async () => {
|
||||||
const ch = new StubChannel();
|
const ch = new StubChannel();
|
||||||
const { session, audit } = mkSession(ch);
|
const { session, audit, client } = mkSession(ch);
|
||||||
await session.close('idle_timeout');
|
await session.close('idle_timeout');
|
||||||
await session.close('idle_timeout');
|
await session.close('idle_timeout');
|
||||||
expect(ch.ended).toBe(true);
|
expect(ch.ended).toBe(true);
|
||||||
|
expect(client.ended).toBe(true);
|
||||||
expect(audit.beginAndComplete).toHaveBeenCalledTimes(1);
|
expect(audit.beginAndComplete).toHaveBeenCalledTimes(1);
|
||||||
const call = audit.beginAndComplete.mock.calls[0]![0];
|
const call = audit.beginAndComplete.mock.calls[0]![0];
|
||||||
expect(call.action).toBe('ssh.console.close');
|
expect(call.action).toBe('ssh.console.close');
|
||||||
expect(call.detail.reason).toBe('idle_timeout');
|
expect(call.detail.reason).toBe('idle_timeout');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('client "error" (ECONNRESET) tears the session down without throwing', async () => {
|
||||||
|
const ch = new StubChannel();
|
||||||
|
const { session, audit, client } = mkSession(ch);
|
||||||
|
// Regression for #407: an unhandled ssh2 Client 'error' event crashes
|
||||||
|
// the whole Node process. ConsoleSession must own a listener so a
|
||||||
|
// dropped transport closes only this session.
|
||||||
|
expect(() => client.emit('error', new Error('read ECONNRESET'))).not.toThrow();
|
||||||
|
// close() runs on the next tick via the catch-less promise chain.
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(session.isClosed).toBe(true);
|
||||||
|
expect(audit.beginAndComplete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(audit.beginAndComplete.mock.calls[0]![0].detail.reason).toBe('host_disconnect');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('client "close" tears the session down once', async () => {
|
||||||
|
const ch = new StubChannel();
|
||||||
|
const { session, audit, client } = mkSession(ch);
|
||||||
|
client.emit('close');
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(session.isClosed).toBe(true);
|
||||||
|
expect(audit.beginAndComplete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(audit.beginAndComplete.mock.calls[0]![0].detail.reason).toBe('host_disconnect');
|
||||||
|
});
|
||||||
|
|
||||||
it('scrollback caps at scrollbackCap', () => {
|
it('scrollback caps at scrollbackCap', () => {
|
||||||
const ch = new StubChannel();
|
const ch = new StubChannel();
|
||||||
const { session } = mkSession(ch);
|
const { session } = mkSession(ch);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import type { Terminal as HeadlessTerminalType } from '@xterm/headless';
|
import type { Terminal as HeadlessTerminalType } from '@xterm/headless';
|
||||||
import type { ClientChannel } from 'ssh2';
|
import type { Client, ClientChannel } from 'ssh2';
|
||||||
import { ByteRingBuffer } from './ring-buffer.js';
|
import { ByteRingBuffer } from './ring-buffer.js';
|
||||||
/** Where an input chunk came from — drives audit `source` + back-pressure label. */
|
/** Where an input chunk came from — drives audit `source` + back-pressure label. */
|
||||||
export type InputSource = 'human' | 'ai';
|
export type InputSource = 'human' | 'ai';
|
||||||
@ -26,6 +26,13 @@ export interface ConsoleSessionArgs {
|
|||||||
rows: number;
|
rows: number;
|
||||||
scrollbackCap: number;
|
scrollbackCap: number;
|
||||||
channel: ClientChannel;
|
channel: ClientChannel;
|
||||||
|
/**
|
||||||
|
* The ssh2 Client backing `channel`. The session owns it: it ends the
|
||||||
|
* client on close() (so the underlying connection/socket doesn't leak)
|
||||||
|
* and reacts to client-level 'error'/'close' so a dropped transport tears
|
||||||
|
* the session down instead of leaving a half-dead session registered.
|
||||||
|
*/
|
||||||
|
client: Client;
|
||||||
auditRepo: SshAuditRepo;
|
auditRepo: SshAuditRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,6 +115,7 @@ export class ConsoleSession {
|
|||||||
rows: number;
|
rows: number;
|
||||||
|
|
||||||
private readonly channel: ClientChannel;
|
private readonly channel: ClientChannel;
|
||||||
|
private readonly client: Client;
|
||||||
private readonly headless: HeadlessTerminal;
|
private readonly headless: HeadlessTerminal;
|
||||||
private readonly scrollback: ByteRingBuffer;
|
private readonly scrollback: ByteRingBuffer;
|
||||||
private readonly auditRepo: SshAuditRepo;
|
private readonly auditRepo: SshAuditRepo;
|
||||||
@ -131,6 +139,7 @@ export class ConsoleSession {
|
|||||||
this.cols = args.cols;
|
this.cols = args.cols;
|
||||||
this.rows = args.rows;
|
this.rows = args.rows;
|
||||||
this.channel = args.channel;
|
this.channel = args.channel;
|
||||||
|
this.client = args.client;
|
||||||
this.scrollback = new ByteRingBuffer(args.scrollbackCap);
|
this.scrollback = new ByteRingBuffer(args.scrollbackCap);
|
||||||
this.auditRepo = args.auditRepo;
|
this.auditRepo = args.auditRepo;
|
||||||
this.headless = new HeadlessTerminal({
|
this.headless = new HeadlessTerminal({
|
||||||
@ -144,14 +153,30 @@ export class ConsoleSession {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.channel.on('data', (data: Buffer) => this.handleOutput(data));
|
this.channel.on('data', (data: Buffer) => this.handleOutput(data));
|
||||||
this.channel.on('close', () => {
|
this.channel.on('close', () => this.onTransportDown());
|
||||||
if (!this.closing) {
|
|
||||||
|
// The Client (connection) can fail independently of the channel:
|
||||||
|
// ssh2 re-emits transport-level socket errors (ECONNRESET on idle
|
||||||
|
// timeout, network drop) as an 'error' on the Client. Without a
|
||||||
|
// listener Node treats an 'error' event as fatal and kills the whole
|
||||||
|
// process — this handler keeps a dropped connection to a single
|
||||||
|
// session-scoped teardown. 'close' covers a graceful peer disconnect.
|
||||||
|
this.client.on('error', (e: Error) => {
|
||||||
|
logger.warn(
|
||||||
|
`[console-session] client error task=${this.localTaskId}: ${(e as Error).message}`,
|
||||||
|
);
|
||||||
|
this.onTransportDown();
|
||||||
|
});
|
||||||
|
this.client.on('close', () => this.onTransportDown());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tear the session down once when the underlying SSH transport drops. */
|
||||||
|
private onTransportDown(): void {
|
||||||
|
if (this.closing) return;
|
||||||
this.close('host_disconnect').catch((e) =>
|
this.close('host_disconnect').catch((e) =>
|
||||||
logger.warn(`[console-session] close error: ${(e as Error).message}`),
|
logger.warn(`[console-session] close error: ${(e as Error).message}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get lastActivityAt(): number {
|
get lastActivityAt(): number {
|
||||||
return this._lastActivityAt;
|
return this._lastActivityAt;
|
||||||
@ -275,6 +300,15 @@ export class ConsoleSession {
|
|||||||
} catch {
|
} catch {
|
||||||
/* already gone */
|
/* already gone */
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
// End the owning Client too — channel.end() only closes the shell
|
||||||
|
// channel, the TCP connection stays up until the client is ended.
|
||||||
|
// Guarded by `closing` above so the resulting 'close' event is a
|
||||||
|
// no-op in onTransportDown.
|
||||||
|
this.client.end();
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
this.headless.dispose();
|
this.headless.dispose();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -219,15 +219,42 @@ async function openClient(
|
|||||||
const settle = (err: Error | null) => {
|
const settle = (err: Error | null) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
client.removeAllListeners('ready');
|
// Detach the handshake-only listeners with the exact references
|
||||||
client.removeAllListeners('error');
|
// (removeAllListeners would also strip the permanent error handler).
|
||||||
client.removeAllListeners('close');
|
client.removeListener('ready', onReady);
|
||||||
if (err) reject(err);
|
client.removeListener('close', onClose);
|
||||||
else resolve(client);
|
if (err) {
|
||||||
|
// Failed handshake: the caller destroys the socket and never uses
|
||||||
|
// this client, so drop the error handler too.
|
||||||
|
client.removeListener('error', onError);
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
// Success: keep `onError` attached. The client can outlive this
|
||||||
|
// call (an interactive console keeps it alive for the whole
|
||||||
|
// session), and ssh2 re-emits transport-level failures
|
||||||
|
// (ECONNRESET on idle timeout, network drop) as an 'error' event
|
||||||
|
// on the Client. A Node 'error' event with no listener is fatal to
|
||||||
|
// the entire process — this permanent handler is the safety net
|
||||||
|
// that turns a dropped connection into a log line instead of a
|
||||||
|
// crash. The live-client owner (ConsoleSession / exec finally)
|
||||||
|
// drives the actual teardown.
|
||||||
|
resolve(client);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
client.once('ready', () => settle(null));
|
const onReady = () => settle(null);
|
||||||
client.once('error', (err: Error) => settle(err));
|
const onClose = () => settle(new Error('connection_closed_during_handshake'));
|
||||||
client.once('close', () => settle(new Error('connection_closed_during_handshake')));
|
const onError = (err: Error) => {
|
||||||
|
if (!settled) {
|
||||||
|
settle(err);
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`[ssh:session] client error after handshake conn=${connection.id}: ${sanitizeError(err).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
client.on('error', onError);
|
||||||
|
client.once('ready', onReady);
|
||||||
|
client.once('close', onClose);
|
||||||
try {
|
try {
|
||||||
client.connect(config);
|
client.connect(config);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@ -702,7 +702,7 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
|
|||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
sandbox="allow-scripts allow-same-origin"
|
sandbox="allow-scripts allow-same-origin"
|
||||||
className="w-full rounded-lg border-0"
|
className="w-full rounded-lg border-0"
|
||||||
style={{ height: '72vh' }}
|
style={{ height: '80vh' }}
|
||||||
title={name}
|
title={name}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -713,13 +713,13 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
|
|||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
type="application/pdf"
|
type="application/pdf"
|
||||||
className="w-full rounded-lg"
|
className="w-full rounded-lg"
|
||||||
style={{ height: '72vh' }}
|
style={{ height: '80vh' }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<img src={imageSrc} alt={name} className="max-w-full max-h-[72vh] object-contain rounded-lg" />
|
<img src={imageSrc} alt={name} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -729,8 +729,9 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
|
|||||||
return <pre className="text-xs whitespace-pre-wrap break-all">{currentContent.slice(0, 100000)}</pre>;
|
return <pre className="text-xs whitespace-pre-wrap break-all">{currentContent.slice(0, 100000)}</pre>;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const isMarkdown = /\.(md|markdown)$/i.test(name);
|
// All preview types share the wide modal so HTML / PDF / images / tables get
|
||||||
const modalWidth = isMarkdown ? 'min(1400px, 96vw)' : 'min(1000px, 94vw)';
|
// the same generous viewport as Markdown (previously non-MD was ~40% narrower).
|
||||||
|
const modalWidth = 'min(1400px, 96vw)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-[env(safe-area-inset-top)_env(safe-area-inset-right)_env(safe-area-inset-bottom)_env(safe-area-inset-left)]" onClick={onClose}>
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-[env(safe-area-inset-top)_env(safe-area-inset-right)_env(safe-area-inset-bottom)_env(safe-area-inset-left)]" onClick={onClose}>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user