@hedgehog-finance/hedgehog-plugin 1.0.19 → 1.0.21

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.
Files changed (46) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/src/channel.js +143 -4
  3. package/dist/src/channel.js.map +1 -1
  4. package/dist/src/core/database.js +156 -0
  5. package/dist/src/core/database.js.map +1 -1
  6. package/dist/src/features/index.js +9 -1
  7. package/dist/src/features/index.js.map +1 -1
  8. package/dist/src/features/notes/schema.d.ts +84 -0
  9. package/dist/src/features/notes/schema.js +40 -0
  10. package/dist/src/features/notes/schema.js.map +1 -0
  11. package/dist/src/features/notes/tools.d.ts +11 -0
  12. package/dist/src/features/notes/tools.js +297 -0
  13. package/dist/src/features/notes/tools.js.map +1 -0
  14. package/dist/src/features/pluginInfo/tools.d.ts +11 -0
  15. package/dist/src/features/pluginInfo/tools.js +49 -0
  16. package/dist/src/features/pluginInfo/tools.js.map +1 -0
  17. package/dist/src/features/profileLibrary/schema.d.ts +29 -0
  18. package/dist/src/features/profileLibrary/schema.js +21 -0
  19. package/dist/src/features/profileLibrary/schema.js.map +1 -0
  20. package/dist/src/features/profileLibrary/tools.d.ts +11 -0
  21. package/dist/src/features/profileLibrary/tools.js +163 -0
  22. package/dist/src/features/profileLibrary/tools.js.map +1 -0
  23. package/dist/src/features/stockAnalysis/schema.d.ts +61 -0
  24. package/dist/src/features/stockAnalysis/schema.js +30 -0
  25. package/dist/src/features/stockAnalysis/schema.js.map +1 -0
  26. package/dist/src/features/stockAnalysis/tools.d.ts +20 -0
  27. package/dist/src/features/stockAnalysis/tools.js +138 -0
  28. package/dist/src/features/stockAnalysis/tools.js.map +1 -0
  29. package/dist/src/features/watchlist/logic.js +7 -60
  30. package/dist/src/features/watchlist/logic.js.map +1 -1
  31. package/dist/src/features/watchlist/tools.js +61 -26
  32. package/dist/src/features/watchlist/tools.js.map +1 -1
  33. package/dist/src/types.d.ts +1 -1
  34. package/package.json +5 -5
  35. package/src/channel.ts +150 -4
  36. package/src/core/database.ts +155 -0
  37. package/src/features/index.ts +9 -1
  38. package/src/features/notes/schema.ts +75 -0
  39. package/src/features/notes/tools.ts +352 -0
  40. package/src/features/pluginInfo/tools.ts +63 -0
  41. package/src/features/profileLibrary/schema.ts +35 -0
  42. package/src/features/profileLibrary/tools.ts +194 -0
  43. package/src/features/stockAnalysis/schema.ts +60 -0
  44. package/src/features/stockAnalysis/tools.ts +192 -0
  45. package/src/features/watchlist/logic.ts +7 -63
  46. package/src/features/watchlist/tools.ts +83 -48
@@ -141,6 +141,100 @@ function runWatchlistDedupMigrations(db: DatabaseSync) {
141
141
  `);
142
142
  }
143
143
 
144
+ function runStockNotesMigrations(db: DatabaseSync) {
145
+ const columns = db.prepare("PRAGMA table_info(stock_notes)").all() as { name: string }[];
146
+ if (columns.length > 0 && !columns.some(column => column.name === "watchlistId")) {
147
+ db.prepare("ALTER TABLE stock_notes ADD COLUMN watchlistId TEXT").run();
148
+ }
149
+ const relationColumns = db.prepare("PRAGMA table_info(stock_note_profile_libraries)").all() as { name: string }[];
150
+ if (relationColumns.length > 0 && !relationColumns.some(column => column.name === "title")) {
151
+ db.prepare("ALTER TABLE stock_note_profile_libraries ADD COLUMN title TEXT NOT NULL DEFAULT ''").run();
152
+ }
153
+ db.exec("CREATE INDEX IF NOT EXISTS idx_stock_notes_user_stock ON stock_notes(userId, watchlistId, updatedAt DESC)");
154
+ }
155
+
156
+ function runStockAiAnalysisMigrations(db: DatabaseSync) {
157
+ const indexes = db.prepare("PRAGMA index_list(stock_ai_analysis)").all() as { name: string; unique: number }[];
158
+ const hasUniqueStockIndex = indexes.some(index => {
159
+ if (index.unique !== 1) return false;
160
+ const columns = db.prepare(`PRAGMA index_info(${index.name})`).all() as { name: string }[];
161
+ const columnNames = columns.map(column => column.name);
162
+ return columnNames.includes("userId") && columnNames.includes("stockCode");
163
+ });
164
+ if (!hasUniqueStockIndex) return;
165
+
166
+ db.exec("BEGIN");
167
+ try {
168
+ db.exec(`
169
+ DROP TABLE IF EXISTS stock_ai_analysis_history;
170
+
171
+ CREATE TABLE stock_ai_analysis_history (
172
+ id TEXT NOT NULL,
173
+ userId TEXT NOT NULL,
174
+ stockCode TEXT NOT NULL,
175
+ stockName TEXT NOT NULL,
176
+ market TEXT NOT NULL DEFAULT 'CN',
177
+ content TEXT NOT NULL,
178
+ createdAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
179
+ updatedAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
180
+ PRIMARY KEY(id, userId)
181
+ );
182
+
183
+ INSERT INTO stock_ai_analysis_history (id, userId, stockCode, stockName, market, content, createdAt, updatedAt)
184
+ SELECT id, userId, stockCode, stockName, market, content, createdAt, updatedAt
185
+ FROM stock_ai_analysis;
186
+
187
+ DROP TABLE stock_ai_analysis;
188
+ ALTER TABLE stock_ai_analysis_history RENAME TO stock_ai_analysis;
189
+ CREATE INDEX IF NOT EXISTS idx_stock_ai_analysis_user_stock_updated ON stock_ai_analysis(userId, stockCode, updatedAt DESC);
190
+ `);
191
+ db.exec("COMMIT");
192
+ } catch (e) {
193
+ if (db.inTransaction) db.exec("ROLLBACK");
194
+ throw e;
195
+ }
196
+ }
197
+
198
+ function runArticleAiAnalysisMigrations(db: DatabaseSync) {
199
+ const columns = db.prepare("PRAGMA table_info(article_ai_analysis)").all() as { name: string }[];
200
+ if (columns.length === 0 || columns.some(column => column.name === "sourceId")) {
201
+ return;
202
+ }
203
+
204
+ db.exec("BEGIN");
205
+ try {
206
+ db.exec(`
207
+ DROP TABLE IF EXISTS article_ai_analysis_v2;
208
+
209
+ CREATE TABLE article_ai_analysis_v2 (
210
+ id TEXT NOT NULL,
211
+ sourceId TEXT NOT NULL,
212
+ userId TEXT NOT NULL,
213
+ analysisType TEXT NOT NULL,
214
+ market TEXT NOT NULL DEFAULT 'CN',
215
+ content TEXT NOT NULL,
216
+ createdAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
217
+ updatedAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
218
+ PRIMARY KEY(id, userId),
219
+ UNIQUE(sourceId, userId, analysisType, market)
220
+ );
221
+
222
+ INSERT INTO article_ai_analysis_v2 (id, sourceId, userId, analysisType, market, content, createdAt, updatedAt)
223
+ SELECT lower(hex(randomblob(16))), id, userId, analysisType, market, content, createdAt, updatedAt
224
+ FROM article_ai_analysis;
225
+
226
+ DROP TABLE article_ai_analysis;
227
+ ALTER TABLE article_ai_analysis_v2 RENAME TO article_ai_analysis;
228
+ CREATE INDEX IF NOT EXISTS idx_article_ai_analysis_user_source_type_updated
229
+ ON article_ai_analysis(userId, sourceId, analysisType, updatedAt DESC);
230
+ `);
231
+ db.exec("COMMIT");
232
+ } catch (e) {
233
+ if (db.inTransaction) db.exec("ROLLBACK");
234
+ throw e;
235
+ }
236
+ }
237
+
144
238
  export function getDB(): DatabaseSync {
145
239
  if (!_db) {
146
240
  const dbPath = getDbPath();
@@ -220,9 +314,70 @@ export function getDB(): DatabaseSync {
220
314
  UNIQUE(watchlistId, categoryId)
221
315
  );
222
316
  CREATE INDEX IF NOT EXISTS idx_watchlist_theme_user ON watchlist_theme_items(userId);
317
+
318
+ CREATE TABLE IF NOT EXISTS profile_libraries (
319
+ id TEXT NOT NULL,
320
+ userId TEXT NOT NULL,
321
+ title TEXT NOT NULL,
322
+ createdAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
323
+ updatedAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
324
+ PRIMARY KEY(id, userId)
325
+ );
326
+ CREATE INDEX IF NOT EXISTS idx_profile_libraries_user_title ON profile_libraries(userId, title);
327
+
328
+ CREATE TABLE IF NOT EXISTS stock_notes (
329
+ id TEXT NOT NULL,
330
+ userId TEXT NOT NULL,
331
+ watchlistId TEXT NOT NULL,
332
+ note TEXT NOT NULL CHECK (length(note) <= 200),
333
+ createdAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
334
+ updatedAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
335
+ PRIMARY KEY(id, userId)
336
+ );
337
+
338
+ CREATE TABLE IF NOT EXISTS stock_note_profile_libraries (
339
+ id TEXT PRIMARY KEY,
340
+ noteId TEXT NOT NULL,
341
+ userId TEXT NOT NULL,
342
+ profileLibraryId TEXT NOT NULL,
343
+ title TEXT NOT NULL,
344
+ createdAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
345
+ UNIQUE(noteId, userId, profileLibraryId)
346
+ );
347
+ CREATE INDEX IF NOT EXISTS idx_stock_note_profile_libraries_user_note ON stock_note_profile_libraries(userId, noteId);
348
+
349
+ CREATE TABLE IF NOT EXISTS stock_ai_analysis (
350
+ id TEXT NOT NULL,
351
+ userId TEXT NOT NULL,
352
+ stockCode TEXT NOT NULL,
353
+ stockName TEXT NOT NULL,
354
+ market TEXT NOT NULL DEFAULT 'CN',
355
+ content TEXT NOT NULL,
356
+ createdAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
357
+ updatedAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
358
+ PRIMARY KEY(id, userId)
359
+ );
360
+ CREATE INDEX IF NOT EXISTS idx_stock_ai_analysis_user_stock_updated ON stock_ai_analysis(userId, stockCode, updatedAt DESC);
361
+
362
+ CREATE TABLE IF NOT EXISTS article_ai_analysis (
363
+ id TEXT NOT NULL,
364
+ sourceId TEXT NOT NULL,
365
+ userId TEXT NOT NULL,
366
+ analysisType TEXT NOT NULL,
367
+ market TEXT NOT NULL DEFAULT 'CN',
368
+ content TEXT NOT NULL,
369
+ createdAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
370
+ updatedAt DATETIME DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')),
371
+ PRIMARY KEY(id, userId),
372
+ UNIQUE(sourceId, userId, analysisType, market)
373
+ );
374
+ CREATE INDEX IF NOT EXISTS idx_article_ai_analysis_user_source_type_updated ON article_ai_analysis(userId, sourceId, analysisType, updatedAt DESC);
223
375
  `);
224
376
 
225
377
  runWatchlistDedupMigrations(_db);
378
+ runStockNotesMigrations(_db);
379
+ runStockAiAnalysisMigrations(_db);
380
+ runArticleAiAnalysisMigrations(_db);
226
381
  }
227
382
  return _db;
228
383
  }
@@ -1,4 +1,8 @@
1
1
  import { watchlistTools } from "./watchlist/tools.js";
2
+ import { profileLibraryTools } from "./profileLibrary/tools.js";
3
+ import { noteTools } from "./notes/tools.js";
4
+ import { stockAnalysisTools } from "./stockAnalysis/tools.js";
5
+ import { pluginInfoTools } from "./pluginInfo/tools.js";
2
6
 
3
7
  export interface RuntimeTool {
4
8
  name: string;
@@ -10,5 +14,9 @@ export interface RuntimeTool {
10
14
  }
11
15
 
12
16
  export const allFeaturesTools: Record<string, RuntimeTool> = {
13
- ...watchlistTools
17
+ ...watchlistTools,
18
+ ...profileLibraryTools,
19
+ ...noteTools,
20
+ ...stockAnalysisTools,
21
+ ...pluginInfoTools
14
22
  };
@@ -0,0 +1,75 @@
1
+ import { z } from "openclaw/plugin-sdk/zod";
2
+
3
+ const ExchangeEnum = z.enum(["SSE", "SZSE", "NASDAQ", "NYSE", "AMEX", "HKEX"]);
4
+ const ProfileLibraryInputSchema = z.object({
5
+ id: z.string().trim().min(1).describe("资料库 ID"),
6
+ title: z.string().trim().min(1).describe("资料库标题")
7
+ });
8
+
9
+ export const AddStockNoteParamsSchema = z.object({
10
+ watchlistId: z.string().trim().min(1).optional().describe("自选股 ID,优先使用该字段绑定股票"),
11
+ stockCode: z.string().trim().min(1).optional().describe("股票代码;未传 watchlistId 时需与 exchange 一起定位股票"),
12
+ exchange: ExchangeEnum.optional().describe("交易所;未传 watchlistId 时需与 stockCode 一起定位股票"),
13
+ note: z.string().trim().min(1).max(200).describe("笔记内容,200 字以内"),
14
+ profileLibraryIds: z.array(ProfileLibraryInputSchema).optional().describe("关联资料库列表,格式为 { id, title }")
15
+ }).refine((value) => Boolean(value.watchlistId || (value.stockCode && value.exchange)), {
16
+ message: "watchlistId 或 stockCode + exchange 必须传一个"
17
+ });
18
+ export type AddStockNoteParams = z.infer<typeof AddStockNoteParamsSchema>;
19
+
20
+ export const DeleteStockNoteParamsSchema = z.object({
21
+ id: z.string().trim().min(1).describe("笔记 ID")
22
+ });
23
+ export type DeleteStockNoteParams = z.infer<typeof DeleteStockNoteParamsSchema>;
24
+
25
+ export const UpdateStockNoteParamsSchema = z.object({
26
+ id: z.string().trim().min(1).describe("笔记 ID"),
27
+ watchlistId: z.string().trim().min(1).optional().describe("自选股 ID;传入则重新绑定股票"),
28
+ stockCode: z.string().trim().min(1).optional().describe("股票代码;与 exchange 一起传入可重新绑定股票"),
29
+ exchange: ExchangeEnum.optional().describe("交易所;与 stockCode 一起传入可重新绑定股票"),
30
+ note: z.string().trim().min(1).max(200).optional().describe("笔记内容,200 字以内"),
31
+ profileLibraryIds: z.array(ProfileLibraryInputSchema).optional().describe("关联资料库列表,格式为 { id, title };传入则覆盖原有关联")
32
+ }).refine((value) => !(value.stockCode && !value.exchange) && !(!value.stockCode && value.exchange), {
33
+ message: "stockCode 与 exchange 必须同时传入"
34
+ });
35
+ export type UpdateStockNoteParams = z.infer<typeof UpdateStockNoteParamsSchema>;
36
+
37
+ export const GetStockNoteByIdParamsSchema = z.object({
38
+ id: z.string().trim().min(1).describe("笔记 ID")
39
+ });
40
+ export type GetStockNoteByIdParams = z.infer<typeof GetStockNoteByIdParamsSchema>;
41
+
42
+ export const QueryStockNotesParamsSchema = z.object({
43
+ watchlistId: z.string().trim().min(1).optional().describe("自选股 ID"),
44
+ stockCode: z.string().trim().min(1).optional().describe("股票代码"),
45
+ exchange: ExchangeEnum.optional().describe("交易所"),
46
+ keyword: z.string().trim().optional().describe("模糊查询关键词,可匹配股票代码、股票名称或笔记内容"),
47
+ page: z.number().int().min(1).optional().describe("页码,从 1 开始,默认 1"),
48
+ pageSize: z.number().int().min(1).max(100).optional().describe("每页数量,默认 20,最大 100")
49
+ });
50
+ export type QueryStockNotesParams = z.infer<typeof QueryStockNotesParamsSchema>;
51
+
52
+ export interface StockNoteRow {
53
+ id: string;
54
+ watchlistId: string;
55
+ stockCode: string;
56
+ stockName: string;
57
+ exchange: string;
58
+ market: string;
59
+ note: string;
60
+ createdAt: string;
61
+ updatedAt: string;
62
+ }
63
+
64
+ export interface StockNoteProfileLibraryRow {
65
+ noteId: string;
66
+ id: string;
67
+ title: string;
68
+ }
69
+
70
+ export interface StockNote extends StockNoteRow {
71
+ profileLibraries: {
72
+ id: string;
73
+ title: string;
74
+ }[];
75
+ }
@@ -0,0 +1,352 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDB } from "../../core/database.js";
3
+ import {
4
+ AddStockNoteParams,
5
+ AddStockNoteParamsSchema,
6
+ DeleteStockNoteParams,
7
+ DeleteStockNoteParamsSchema,
8
+ GetStockNoteByIdParams,
9
+ GetStockNoteByIdParamsSchema,
10
+ QueryStockNotesParams,
11
+ QueryStockNotesParamsSchema,
12
+ StockNote,
13
+ StockNoteProfileLibraryRow,
14
+ StockNoteRow,
15
+ UpdateStockNoteParams,
16
+ UpdateStockNoteParamsSchema
17
+ } from "./schema.js";
18
+
19
+ interface RuntimeTool {
20
+ name: string;
21
+ description: string;
22
+ parameters: unknown;
23
+ registerTool?: boolean;
24
+ execute(params: unknown, ctx: { userId: string }): Promise<string>;
25
+ }
26
+
27
+ interface WatchlistStock {
28
+ id: string;
29
+ stockCode: string;
30
+ stockName: string;
31
+ exchange: string;
32
+ market: string;
33
+ }
34
+
35
+ function escapeLikePattern(value: string): string {
36
+ return value.replace(/[\\%_]/g, (char) => `\\${char}`);
37
+ }
38
+
39
+ function normalizePagination(args: { page?: number; pageSize?: number }) {
40
+ const page = args.page ?? 1;
41
+ const pageSize = args.pageSize ?? 20;
42
+ return {
43
+ page,
44
+ pageSize,
45
+ offset: (page - 1) * pageSize
46
+ };
47
+ }
48
+
49
+ function uniqueProfileLibraries(libraries: { id: string; title: string }[] | undefined): { id: string; title: string }[] {
50
+ const byId = new Map<string, string>();
51
+ for (const library of libraries || []) {
52
+ const id = library.id.trim();
53
+ const title = library.title.trim();
54
+ if (id && title && !byId.has(id)) {
55
+ byId.set(id, title);
56
+ }
57
+ }
58
+ return Array.from(byId, ([id, title]) => ({ id, title }));
59
+ }
60
+
61
+ function runInTransaction<T>(db: ReturnType<typeof getDB>, task: () => T): T {
62
+ const savepoint = `note_tx_${randomUUID().replace(/-/g, "")}`;
63
+ db.exec(`SAVEPOINT ${savepoint}`);
64
+ try {
65
+ const result = task();
66
+ db.exec(`RELEASE SAVEPOINT ${savepoint}`);
67
+ return result;
68
+ } catch (e) {
69
+ db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
70
+ db.exec(`RELEASE SAVEPOINT ${savepoint}`);
71
+ throw e;
72
+ }
73
+ }
74
+
75
+ function getNoteSelectSql(whereSql: string): string {
76
+ return `
77
+ SELECT
78
+ sn.id,
79
+ sn.watchlistId,
80
+ w.stockCode,
81
+ w.stockName,
82
+ w.exchange,
83
+ w.market,
84
+ sn.note,
85
+ sn.createdAt,
86
+ sn.updatedAt
87
+ FROM stock_notes sn
88
+ JOIN watchlist w ON w.id = sn.watchlistId AND w.userId = sn.userId
89
+ WHERE ${whereSql}
90
+ `;
91
+ }
92
+
93
+ function resolveWatchlistStock(
94
+ db: ReturnType<typeof getDB>,
95
+ userId: string,
96
+ args: { watchlistId?: string; stockCode?: string; exchange?: string }
97
+ ): WatchlistStock | null {
98
+ if (args.watchlistId) {
99
+ return db.prepare(`
100
+ SELECT id, stockCode, stockName, exchange, market
101
+ FROM watchlist
102
+ WHERE id = ? AND userId = ? AND isDeleted = 0
103
+ `).get(args.watchlistId.trim(), userId) as WatchlistStock | undefined || null;
104
+ }
105
+
106
+ if (args.stockCode && args.exchange) {
107
+ return db.prepare(`
108
+ SELECT id, stockCode, stockName, exchange, market
109
+ FROM watchlist
110
+ WHERE userId = ? AND stockCode = ? AND exchange = ? AND isDeleted = 0
111
+ `).get(userId, args.stockCode.trim(), args.exchange) as WatchlistStock | undefined || null;
112
+ }
113
+
114
+ return null;
115
+ }
116
+
117
+ function attachProfileLibraries(db: ReturnType<typeof getDB>, userId: string, rows: StockNoteRow[]): StockNote[] {
118
+ if (rows.length === 0) return [];
119
+ const noteIds = rows.map(row => row.id);
120
+ const libraryRows = db.prepare(`
121
+ SELECT npl.noteId, npl.profileLibraryId AS id, npl.title
122
+ FROM stock_note_profile_libraries npl
123
+ WHERE npl.userId = ? AND npl.noteId IN (${noteIds.map(() => "?").join(",")})
124
+ ORDER BY npl.noteId, npl.createdAt ASC
125
+ `).all(userId, ...noteIds) as StockNoteProfileLibraryRow[];
126
+
127
+ const librariesByNoteId = new Map<string, { id: string; title: string }[]>();
128
+ for (const row of libraryRows) {
129
+ const list = librariesByNoteId.get(row.noteId) || [];
130
+ list.push({ id: row.id, title: row.title });
131
+ librariesByNoteId.set(row.noteId, list);
132
+ }
133
+
134
+ return rows.map(row => {
135
+ const libs = librariesByNoteId.get(row.id) || [];
136
+ return {
137
+ ...row,
138
+ profileLibraries: libs
139
+ };
140
+ });
141
+ }
142
+
143
+ function successWithPagination(data: StockNote[], page: number, pageSize: number, total: number) {
144
+ return JSON.stringify({
145
+ success: true,
146
+ data,
147
+ pagination: {
148
+ page,
149
+ pageSize,
150
+ total,
151
+ totalPages: Math.ceil(total / pageSize)
152
+ }
153
+ });
154
+ }
155
+
156
+ export const noteTools: Record<string, RuntimeTool> = {
157
+ add_stock_note: {
158
+ name: "add_stock_note",
159
+ description: "新增股票笔记,笔记内容 200 字以内,可关联资料库列表;profileLibraryIds 格式为 { id, title }",
160
+ parameters: AddStockNoteParamsSchema,
161
+ registerTool: false,
162
+ execute: async (args: AddStockNoteParams, ctx: { userId: string }) => {
163
+ try {
164
+ const db = getDB();
165
+ const uId = String(ctx.userId);
166
+ const id = randomUUID();
167
+ const stock = resolveWatchlistStock(db, uId, args);
168
+ if (!stock) {
169
+ return JSON.stringify({ success: false, error: "股票不存在或未在自选列表中" });
170
+ }
171
+ const profileLibraries = uniqueProfileLibraries(args.profileLibraryIds);
172
+
173
+ runInTransaction(db, () => {
174
+ db.prepare(`
175
+ INSERT INTO stock_notes (id, userId, watchlistId, note)
176
+ VALUES (?, ?, ?, ?)
177
+ `).run(id, uId, stock.id, args.note);
178
+
179
+ const insertRelation = db.prepare(`
180
+ INSERT INTO stock_note_profile_libraries (id, noteId, userId, profileLibraryId, title)
181
+ VALUES (?, ?, ?, ?, ?)
182
+ `);
183
+ profileLibraries.forEach((profileLibrary) => {
184
+ insertRelation.run(randomUUID(), id, uId, profileLibrary.id, profileLibrary.title);
185
+ });
186
+ });
187
+
188
+ const row = db.prepare(getNoteSelectSql("sn.id = ? AND sn.userId = ?")).get(id, uId) as StockNoteRow;
189
+ return JSON.stringify({ success: true, data: attachProfileLibraries(db, uId, [row])[0] });
190
+ } catch (e: any) {
191
+ return JSON.stringify({ success: false, error: e.message });
192
+ }
193
+ }
194
+ },
195
+
196
+ delete_stock_note: {
197
+ name: "delete_stock_note",
198
+ description: "删除股票笔记,并清理笔记关联资料库记录",
199
+ parameters: DeleteStockNoteParamsSchema,
200
+ registerTool: false,
201
+ execute: async (args: DeleteStockNoteParams, ctx: { userId: string }) => {
202
+ try {
203
+ const db = getDB();
204
+ const uId = String(ctx.userId);
205
+ const id = args.id.trim();
206
+
207
+ const info = runInTransaction(db, () => {
208
+ db.prepare("DELETE FROM stock_note_profile_libraries WHERE noteId = ? AND userId = ?").run(id, uId);
209
+ return db.prepare("DELETE FROM stock_notes WHERE id = ? AND userId = ?").run(id, uId);
210
+ });
211
+ return JSON.stringify({ success: info.changes > 0 });
212
+ } catch (e: any) {
213
+ return JSON.stringify({ success: false, error: e.message });
214
+ }
215
+ }
216
+ },
217
+
218
+ update_stock_note: {
219
+ name: "update_stock_note",
220
+ description: "修改股票笔记;传入 profileLibraryIds 时会覆盖原有关联资料库列表;profileLibraryIds 格式为 { id, title }",
221
+ parameters: UpdateStockNoteParamsSchema,
222
+ registerTool: false,
223
+ execute: async (args: UpdateStockNoteParams, ctx: { userId: string }) => {
224
+ try {
225
+ const db = getDB();
226
+ const uId = String(ctx.userId);
227
+ const id = args.id.trim();
228
+ const existing = db.prepare(`
229
+ SELECT id
230
+ FROM stock_notes
231
+ WHERE id = ? AND userId = ?
232
+ `).get(id, uId);
233
+ if (!existing) {
234
+ return JSON.stringify({ success: false, error: "笔记不存在" });
235
+ }
236
+
237
+ const stock = (args.watchlistId || args.stockCode || args.exchange)
238
+ ? resolveWatchlistStock(db, uId, args)
239
+ : null;
240
+ if ((args.watchlistId || args.stockCode || args.exchange) && !stock) {
241
+ return JSON.stringify({ success: false, error: "股票不存在或未在自选列表中" });
242
+ }
243
+ const profileLibraries = args.profileLibraryIds === undefined ? undefined : uniqueProfileLibraries(args.profileLibraryIds);
244
+
245
+ runInTransaction(db, () => {
246
+ const updates: string[] = ["updatedAt = STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')"];
247
+ const updateParams: unknown[] = [];
248
+ if (stock) {
249
+ updates.unshift("watchlistId = ?");
250
+ updateParams.push(stock.id);
251
+ }
252
+ if (args.note !== undefined) {
253
+ updates.unshift("note = ?");
254
+ updateParams.unshift(args.note);
255
+ }
256
+ db.prepare(`
257
+ UPDATE stock_notes SET ${updates.join(", ")}
258
+ WHERE id = ? AND userId = ?
259
+ `).run(...updateParams, id, uId);
260
+ if (profileLibraries) {
261
+ db.prepare(`
262
+ DELETE FROM stock_note_profile_libraries
263
+ WHERE userId = ? AND noteId = ?
264
+ `).run(uId, id);
265
+
266
+ const insertRelation = db.prepare(`
267
+ INSERT INTO stock_note_profile_libraries (id, noteId, userId, profileLibraryId, title)
268
+ VALUES (?, ?, ?, ?, ?)
269
+ `);
270
+ profileLibraries.forEach((profileLibrary) => {
271
+ insertRelation.run(randomUUID(), id, uId, profileLibrary.id, profileLibrary.title);
272
+ });
273
+ }
274
+ });
275
+
276
+ const row = db.prepare(getNoteSelectSql("sn.id = ? AND sn.userId = ?")).get(id, uId) as StockNoteRow;
277
+ return JSON.stringify({ success: true, data: attachProfileLibraries(db, uId, [row])[0] });
278
+ } catch (e: any) {
279
+ return JSON.stringify({ success: false, error: e.message });
280
+ }
281
+ }
282
+ },
283
+
284
+ get_stock_note_by_id: {
285
+ name: "get_stock_note_by_id",
286
+ description: "根据 ID 查询股票笔记详情",
287
+ parameters: GetStockNoteByIdParamsSchema,
288
+ registerTool: false,
289
+ execute: async (args: GetStockNoteByIdParams, ctx: { userId: string }) => {
290
+ try {
291
+ const db = getDB();
292
+ const uId = String(ctx.userId);
293
+ const row = db.prepare(getNoteSelectSql("sn.id = ? AND sn.userId = ?")).get(args.id.trim(), uId) as StockNoteRow | undefined;
294
+
295
+ return JSON.stringify({ success: true, data: row ? attachProfileLibraries(db, uId, [row])[0] : null });
296
+ } catch (e: any) {
297
+ return JSON.stringify({ success: false, error: e.message });
298
+ }
299
+ }
300
+ },
301
+
302
+ query_stock_notes: {
303
+ name: "query_stock_notes",
304
+ description: "分页查询股票笔记,支持按股票代码、交易所和关键词过滤",
305
+ parameters: QueryStockNotesParamsSchema,
306
+ registerTool: false,
307
+ execute: async (args: QueryStockNotesParams = {}, ctx: { userId: string }) => {
308
+ try {
309
+ const db = getDB();
310
+ const uId = String(ctx.userId);
311
+ const { page, pageSize, offset } = normalizePagination(args);
312
+ const conditions = ["sn.userId = ?", "w.isDeleted = 0"];
313
+ const params: unknown[] = [uId];
314
+
315
+ if (args.watchlistId) {
316
+ conditions.push("sn.watchlistId = ?");
317
+ params.push(args.watchlistId);
318
+ }
319
+ if (args.stockCode) {
320
+ conditions.push("w.stockCode = ?");
321
+ params.push(args.stockCode);
322
+ }
323
+ if (args.exchange) {
324
+ conditions.push("w.exchange = ?");
325
+ params.push(args.exchange);
326
+ }
327
+ const keyword = args.keyword?.trim();
328
+ if (keyword) {
329
+ const pattern = `%${escapeLikePattern(keyword)}%`;
330
+ conditions.push("(w.stockCode LIKE ? ESCAPE '\\' OR w.stockName LIKE ? ESCAPE '\\' OR sn.note LIKE ? ESCAPE '\\')");
331
+ params.push(pattern, pattern, pattern);
332
+ }
333
+
334
+ const whereSql = conditions.join(" AND ");
335
+ const total = (db.prepare(`
336
+ SELECT COUNT(*) AS count
337
+ FROM stock_notes sn
338
+ JOIN watchlist w ON w.id = sn.watchlistId AND w.userId = sn.userId
339
+ WHERE ${whereSql}
340
+ `).get(...params) as { count: number }).count;
341
+ const rows = db.prepare(`${getNoteSelectSql(whereSql)}
342
+ ORDER BY sn.updatedAt DESC, sn.createdAt DESC
343
+ LIMIT ? OFFSET ?
344
+ `).all(...params, pageSize, offset) as StockNoteRow[];
345
+
346
+ return successWithPagination(attachProfileLibraries(db, uId, rows), page, pageSize, total);
347
+ } catch (e: any) {
348
+ return JSON.stringify({ success: false, error: e.message });
349
+ }
350
+ }
351
+ }
352
+ };
@@ -0,0 +1,63 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { z } from "openclaw/plugin-sdk/zod";
5
+
6
+ interface RuntimeTool {
7
+ name: string;
8
+ description: string;
9
+ parameters: unknown;
10
+ registerTool?: boolean;
11
+ execute(params: unknown, ctx: { userId: string }): Promise<string>;
12
+ }
13
+
14
+ const GetPluginVersionParamsSchema = z.object({}).nullish();
15
+
16
+ let cachedPluginVersion: string | null = null;
17
+
18
+ function findPackageJsonPath(startDir: string): string | null {
19
+ let currentDir = startDir;
20
+
21
+ while (true) {
22
+ const candidate = path.join(currentDir, "package.json");
23
+ if (fs.existsSync(candidate)) return candidate;
24
+
25
+ const parentDir = path.dirname(currentDir);
26
+ if (parentDir === currentDir) return null;
27
+ currentDir = parentDir;
28
+ }
29
+ }
30
+
31
+ function getPluginVersion(): string {
32
+ if (cachedPluginVersion) return cachedPluginVersion;
33
+
34
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
35
+ const packageJsonPath = findPackageJsonPath(moduleDir);
36
+ if (!packageJsonPath) {
37
+ throw new Error("无法找到插件 package.json");
38
+ }
39
+
40
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { version?: unknown };
41
+ if (typeof packageJson.version !== "string" || !packageJson.version.trim()) {
42
+ throw new Error("插件 package.json 缺少有效版本号");
43
+ }
44
+
45
+ cachedPluginVersion = packageJson.version.trim();
46
+ return cachedPluginVersion;
47
+ }
48
+
49
+ export const pluginInfoTools: Record<string, RuntimeTool> = {
50
+ get_plugin_version: {
51
+ name: "get_plugin_version",
52
+ description: "获取当前 Hedgehog 插件版本号",
53
+ parameters: GetPluginVersionParamsSchema,
54
+ registerTool: false,
55
+ async execute(params: unknown) {
56
+ GetPluginVersionParamsSchema.parse(params);
57
+ return JSON.stringify({
58
+ success: true,
59
+ version: getPluginVersion()
60
+ });
61
+ }
62
+ }
63
+ };