geomind 0.1.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.
@@ -0,0 +1,330 @@
1
+ import type { AgentEndEvent, ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+
7
+ const MEMORY_CATEGORIES = ["database", "user"] as const;
8
+ type MemoryCategory = (typeof MEMORY_CATEGORIES)[number];
9
+
10
+ const REFLECTION_INTERVAL = 5;
11
+
12
+ const MEMORY_FILES = {
13
+ database: [
14
+ {
15
+ key: "schema-overview",
16
+ title: "Database schema overview",
17
+ description: "数据库整体概览(有哪些表、各表用途一句话总结)",
18
+ },
19
+ {
20
+ key: "table-details",
21
+ title: "Database table details",
22
+ description: "各表详细结构(列名、类型、SRID、数据量、重要字段含义)",
23
+ },
24
+ {
25
+ key: "query-patterns",
26
+ title: "Database query patterns",
27
+ description: "有用的 SQL 模式、踩过的坑",
28
+ },
29
+ ],
30
+ user: [
31
+ {
32
+ key: "profile",
33
+ title: "User profile",
34
+ description: "用户身份(姓名、职业、研究背景)",
35
+ },
36
+ {
37
+ key: "preferences",
38
+ title: "User preferences",
39
+ description: "用户偏好(常用区域、分析习惯、展示偏好)",
40
+ },
41
+ {
42
+ key: "corrections",
43
+ title: "User corrections",
44
+ description: "用户纠错记录",
45
+ },
46
+ ],
47
+ } as const;
48
+
49
+ type DatabaseMemoryKey = (typeof MEMORY_FILES.database)[number]["key"];
50
+ type UserMemoryKey = (typeof MEMORY_FILES.user)[number]["key"];
51
+ type MemoryKey = DatabaseMemoryKey | UserMemoryKey;
52
+
53
+ const CATEGORY_TITLES: Record<MemoryCategory, string> = {
54
+ database: "Database Knowledge",
55
+ user: "User Preferences",
56
+ };
57
+
58
+ function getGlobalMemoryDir(): string {
59
+ return path.join(os.homedir(), ".geomind", "memory");
60
+ }
61
+
62
+ function getProjectMemoryDir(cwd: string): string {
63
+ return path.join(cwd, ".geomind", "memory");
64
+ }
65
+
66
+ function getCategoryMemoryDir(cwd: string, category: MemoryCategory): string {
67
+ const memoryDir = category === "user" ? getGlobalMemoryDir() : getProjectMemoryDir(cwd);
68
+ return path.join(memoryDir, category);
69
+ }
70
+
71
+ function ensureDir(dir: string) {
72
+ if (!fs.existsSync(dir)) {
73
+ fs.mkdirSync(dir, { recursive: true });
74
+ }
75
+ }
76
+
77
+ function stripFrontmatter(content: string): string {
78
+ return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim();
79
+ }
80
+
81
+ function getMemoryFiles(category: MemoryCategory) {
82
+ return MEMORY_FILES[category];
83
+ }
84
+
85
+ function isMemoryKeyForCategory(category: MemoryCategory, key: MemoryKey): boolean {
86
+ return getMemoryFiles(category).some((file) => file.key === key);
87
+ }
88
+
89
+ function getMemoryFilePath(cwd: string, category: MemoryCategory, key: MemoryKey): string {
90
+ return path.join(getCategoryMemoryDir(cwd, category), `${key}.md`);
91
+ }
92
+
93
+ function readUpdatedFromFrontmatter(content: string): string | undefined {
94
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
95
+ if (!match) return undefined;
96
+
97
+ const updatedLine = match[1].split(/\r?\n/).find((line) => line.startsWith("updated:"));
98
+ return updatedLine?.replace(/^updated:\s*/, "").trim();
99
+ }
100
+
101
+ function readMemoryEntries(cwd: string, category: MemoryCategory) {
102
+ const categoryDir = getCategoryMemoryDir(cwd, category);
103
+ if (!fs.existsSync(categoryDir)) return [];
104
+
105
+ return getMemoryFiles(category)
106
+ .map(({ key }) => {
107
+ const filePath = getMemoryFilePath(cwd, category, key);
108
+ if (!fs.existsSync(filePath)) return undefined;
109
+
110
+ const content = fs.readFileSync(filePath, "utf-8");
111
+ return { key, content: stripFrontmatter(content) };
112
+ })
113
+ .filter((entry): entry is { key: MemoryKey; content: string } => Boolean(entry?.content));
114
+ }
115
+
116
+ function buildMemoryContext(cwd: string): string {
117
+ const sections: string[] = [];
118
+
119
+ for (const category of MEMORY_CATEGORIES) {
120
+ const entries = readMemoryEntries(cwd, category);
121
+ if (entries.length === 0) continue;
122
+
123
+ const body = entries.map((entry) => `[${entry.key}]\n${entry.content}`).join("\n\n");
124
+ sections.push(`### ${CATEGORY_TITLES[category]}\n${body}`);
125
+ }
126
+
127
+ if (sections.length === 0) return "";
128
+ return `## Your Current Memory\n\n${sections.join("\n\n")}`;
129
+ }
130
+
131
+ function hasMemoryReflection(messages: AgentEndEvent["messages"]): boolean {
132
+ return messages.some(
133
+ (message) => message.role === "custom" && message.customType === "memory-reflection"
134
+ );
135
+ }
136
+
137
+ const memoryCategorySchema = Type.Union([Type.Literal("database"), Type.Literal("user")]);
138
+ const memoryKeySchema = Type.Union([
139
+ Type.Literal("schema-overview"),
140
+ Type.Literal("table-details"),
141
+ Type.Literal("query-patterns"),
142
+ Type.Literal("profile"),
143
+ Type.Literal("preferences"),
144
+ Type.Literal("corrections"),
145
+ ]);
146
+
147
+ export default function (pi: ExtensionAPI) {
148
+ let turnsSinceLastReflection = 0;
149
+
150
+ pi.on("before_agent_start", async (event, ctx) => {
151
+ const memoryContext = buildMemoryContext(ctx.cwd);
152
+ if (!memoryContext) return;
153
+
154
+ return {
155
+ systemPrompt: `${event.systemPrompt.trimEnd()}\n\n${memoryContext}`,
156
+ };
157
+ });
158
+
159
+ pi.on("agent_end", async (event) => {
160
+ if (hasMemoryReflection(event.messages)) {
161
+ turnsSinceLastReflection = 0;
162
+ return;
163
+ }
164
+
165
+ turnsSinceLastReflection += 1;
166
+ if (turnsSinceLastReflection < REFLECTION_INTERVAL) return;
167
+ turnsSinceLastReflection = 0;
168
+
169
+ pi.sendMessage(
170
+ {
171
+ customType: "memory-reflection",
172
+ content: `请回顾本轮对话(最近5轮),提炼值得长期记忆的信息。
173
+
174
+ 步骤:
175
+ 1. 先用 memory_list 查看当前有哪些记忆
176
+ 2. 对于需要更新的记忆,先 memory_read 读取已有内容
177
+ 3. 将新知识合并到已有内容中,用 memory_write 写回
178
+
179
+ 可用的记忆 key:
180
+ - database/schema-overview:数据库整体概览(有哪些表、各表用途一句话总结)
181
+ - database/table-details:各表详细结构(列名、类型、SRID、数据量、重要字段含义)
182
+ - database/query-patterns:有用的 SQL 模式、踩过的坑
183
+ - user/profile:用户身份(姓名、职业、研究背景)
184
+ - user/preferences:用户偏好(常用区域、分析习惯、展示偏好)
185
+ - user/corrections:用户纠错记录
186
+
187
+ 规则:
188
+ - 不要创建新的 key,只能用上面列出的 6 个
189
+ - 如果某个 key 已有内容,读取后合并新信息,不要覆盖丢失旧信息
190
+ - 如果没有值得记忆的新信息,直接说"无需更新记忆"
191
+ - 记忆内容要简洁结构化,用 markdown 列表或表格组织`,
192
+ display: false,
193
+ },
194
+ {
195
+ triggerTurn: true,
196
+ deliverAs: "followUp",
197
+ }
198
+ );
199
+ });
200
+
201
+ pi.registerTool({
202
+ name: "memory_write",
203
+ label: "Memory Write",
204
+ description:
205
+ "Save knowledge to one of GeoMind's fixed persistent memory files. Existing content is overwritten; read and merge first when preserving old information.",
206
+ parameters: Type.Object({
207
+ category: memoryCategorySchema,
208
+ key: memoryKeySchema,
209
+ content: Type.String({
210
+ description:
211
+ "要写入的完整内容。会覆盖该 key 的已有内容。如果要追加信息,请先用 memory_read 读取已有内容,合并后再写入。",
212
+ }),
213
+ }),
214
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
215
+ const { category, key, content } = params;
216
+ if (!isMemoryKeyForCategory(category, key)) {
217
+ return {
218
+ content: [
219
+ {
220
+ type: "text" as const,
221
+ text: `Invalid memory key [${category}/${key}]. Use memory_list to see the fixed keys for each category.`,
222
+ },
223
+ ],
224
+ details: {},
225
+ };
226
+ }
227
+
228
+ const categoryDir = getCategoryMemoryDir(ctx.cwd, category);
229
+ ensureDir(categoryDir);
230
+
231
+ const filePath = getMemoryFilePath(ctx.cwd, category, key);
232
+ const timestamp = new Date().toISOString();
233
+
234
+ const fileContent = `---
235
+ key: ${key}
236
+ category: ${category}
237
+ updated: ${timestamp}
238
+ ---
239
+
240
+ ${content}
241
+ `;
242
+
243
+ fs.writeFileSync(filePath, fileContent, "utf-8");
244
+
245
+ return {
246
+ content: [
247
+ {
248
+ type: "text" as const,
249
+ text: `Memory saved: [${category}/${key}] at ${timestamp}`,
250
+ },
251
+ ],
252
+ details: {},
253
+ };
254
+ },
255
+ });
256
+
257
+ pi.registerTool({
258
+ name: "memory_read",
259
+ label: "Memory Read",
260
+ description: "Read a specific memory entry by category and key.",
261
+ parameters: Type.Object({
262
+ category: memoryCategorySchema,
263
+ key: memoryKeySchema,
264
+ }),
265
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
266
+ const { category, key } = params;
267
+ if (!isMemoryKeyForCategory(category, key)) {
268
+ return {
269
+ content: [
270
+ {
271
+ type: "text" as const,
272
+ text: `Invalid memory key [${category}/${key}]. Use memory_list to see the fixed keys for each category.`,
273
+ },
274
+ ],
275
+ details: {},
276
+ };
277
+ }
278
+
279
+ const filePath = getMemoryFilePath(ctx.cwd, category, key);
280
+
281
+ if (!fs.existsSync(filePath)) {
282
+ return {
283
+ content: [
284
+ {
285
+ type: "text" as const,
286
+ text: `No memory found for [${category}/${key}]`,
287
+ },
288
+ ],
289
+ details: {},
290
+ };
291
+ }
292
+
293
+ const content = fs.readFileSync(filePath, "utf-8");
294
+ return {
295
+ content: [{ type: "text" as const, text: content }],
296
+ details: {},
297
+ };
298
+ },
299
+ });
300
+
301
+ pi.registerTool({
302
+ name: "memory_list",
303
+ label: "Memory List",
304
+ description: "List GeoMind's fixed memory files and show whether each one exists.",
305
+ parameters: Type.Object({}),
306
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
307
+ const results: string[] = [];
308
+
309
+ for (const category of MEMORY_CATEGORIES) {
310
+ results.push(`\n[${category}] ${getCategoryMemoryDir(ctx.cwd, category)}`);
311
+ for (const file of getMemoryFiles(category)) {
312
+ const filePath = getMemoryFilePath(ctx.cwd, category, file.key);
313
+ if (!fs.existsSync(filePath)) {
314
+ results.push(` - ${file.key}: missing (${file.description})`);
315
+ continue;
316
+ }
317
+
318
+ const content = fs.readFileSync(filePath, "utf-8");
319
+ const updated = readUpdatedFromFrontmatter(content) ?? fs.statSync(filePath).mtime.toISOString();
320
+ results.push(` - ${file.key}: exists, updated ${updated} (${file.description})`);
321
+ }
322
+ }
323
+
324
+ return {
325
+ content: [{ type: "text" as const, text: results.join("\n").trim() }],
326
+ details: {},
327
+ };
328
+ },
329
+ });
330
+ }
@@ -0,0 +1,247 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { spawnSync } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+
7
+ interface GeoMindConfig {
8
+ psql?: {
9
+ command: string;
10
+ database: string;
11
+ user?: string;
12
+ host?: string;
13
+ port?: number;
14
+ };
15
+ qgis_process?: {
16
+ path: string;
17
+ available: boolean;
18
+ };
19
+ }
20
+
21
+ interface CommandResult {
22
+ ok: boolean;
23
+ stdout: string;
24
+ stderr: string;
25
+ }
26
+
27
+ const LOCAL_HOSTS = new Set(["", "localhost", "127.0.0.1", "::1"]);
28
+
29
+ function getGlobalDir(): string {
30
+ return path.join(os.homedir(), ".geomind");
31
+ }
32
+
33
+ function getConfigPath(cwd: string, scope: "global" | "project"): string {
34
+ return scope === "global"
35
+ ? path.join(getGlobalDir(), "config.json")
36
+ : path.join(cwd, ".geomind", "config.json");
37
+ }
38
+
39
+ function runCommand(command: string, args: string[], timeout = 5_000): CommandResult {
40
+ const result = spawnSync(command, args, {
41
+ encoding: "utf-8",
42
+ timeout,
43
+ shell: false,
44
+ });
45
+
46
+ return {
47
+ ok: result.status === 0,
48
+ stdout: result.stdout?.toString() ?? "",
49
+ stderr: result.stderr?.toString() ?? "",
50
+ };
51
+ }
52
+
53
+ function findExecutable(command: string): string | undefined {
54
+ const lookupCommand = process.platform === "win32" ? "where" : "which";
55
+ const result = runCommand(lookupCommand, [command]);
56
+ if (!result.ok) return undefined;
57
+ return result.stdout.trim().split(/\r?\n/)[0] || undefined;
58
+ }
59
+
60
+ function parsePort(value: string | undefined): number | undefined {
61
+ if (!value?.trim()) return undefined;
62
+ const port = Number(value.trim());
63
+ return Number.isInteger(port) && port > 0 ? port : undefined;
64
+ }
65
+
66
+ function parseDatabaseList(outputText: string): string[] {
67
+ const names = outputText
68
+ .split(/\r?\n/)
69
+ .map((line) => line.split("|")[0]?.trim())
70
+ .filter((name): name is string => Boolean(name));
71
+
72
+ return [...new Set(names)].sort((a, b) => a.localeCompare(b));
73
+ }
74
+
75
+ function chooseDefaultDatabase(databases: string[]): string {
76
+ if (databases.includes("ggai")) return "ggai";
77
+ if (databases.includes("postgres")) return "postgres";
78
+ return databases.find((name) => !name.startsWith("template")) ?? "ggai";
79
+ }
80
+
81
+ function isLocalHost(host: string | undefined): boolean {
82
+ return LOCAL_HOSTS.has(host?.trim().toLowerCase() ?? "");
83
+ }
84
+
85
+ function getPsqlListArgs(config: NonNullable<GeoMindConfig["psql"]>): string[] {
86
+ const args = ["-lqt"];
87
+
88
+ if (config.host) {
89
+ args.push("-h", config.host);
90
+ }
91
+ if (config.port) {
92
+ args.push("-p", String(config.port));
93
+ }
94
+ if (config.user) {
95
+ args.push("-U", config.user);
96
+ }
97
+
98
+ return args;
99
+ }
100
+
101
+ function getQgisSearchPaths(): string[] {
102
+ if (process.platform === "win32") {
103
+ return [
104
+ "C:\\Program Files\\QGIS 3.40\\bin\\qgis_process.exe",
105
+ "C:\\Program Files\\QGIS 3.38\\bin\\qgis_process.exe",
106
+ "C:\\Program Files\\QGIS 3.34\\bin\\qgis_process.exe",
107
+ "C:\\OSGeo4W\\bin\\qgis_process.exe",
108
+ ];
109
+ }
110
+
111
+ if (process.platform === "darwin") {
112
+ return [
113
+ "/Applications/QGIS-LTR.app/Contents/MacOS/bin/qgis_process",
114
+ "/Applications/QGIS.app/Contents/MacOS/bin/qgis_process",
115
+ ];
116
+ }
117
+
118
+ return ["/usr/bin/qgis_process", "/usr/local/bin/qgis_process"];
119
+ }
120
+
121
+ function verifyQgisProcess(candidate: string): boolean {
122
+ if (!fs.existsSync(candidate)) return false;
123
+ return runCommand(candidate, ["--version"], 5_000).ok;
124
+ }
125
+
126
+ function detectQgisProcess(): NonNullable<GeoMindConfig["qgis_process"]> {
127
+ const candidates = [
128
+ findExecutable(process.platform === "win32" ? "qgis_process.exe" : "qgis_process"),
129
+ ...getQgisSearchPaths(),
130
+ ].filter((candidate): candidate is string => Boolean(candidate));
131
+
132
+ for (const candidate of [...new Set(candidates)]) {
133
+ if (verifyQgisProcess(candidate)) {
134
+ return {
135
+ path: candidate,
136
+ available: true,
137
+ };
138
+ }
139
+ }
140
+
141
+ return {
142
+ path: "",
143
+ available: false,
144
+ };
145
+ }
146
+
147
+ async function askInput(
148
+ ctx: ExtensionCommandContext,
149
+ prompt: string,
150
+ placeholder?: string
151
+ ): Promise<string | undefined> {
152
+ return ctx.ui.input(prompt, placeholder);
153
+ }
154
+
155
+ async function selectDatabase(
156
+ ctx: ExtensionCommandContext,
157
+ psqlCommand: string,
158
+ partialConfig: NonNullable<GeoMindConfig["psql"]>
159
+ ): Promise<string> {
160
+ if (!isLocalHost(partialConfig.host)) {
161
+ const database = await askInput(ctx, "请输入数据库名", "ggai");
162
+ return database?.trim() || "ggai";
163
+ }
164
+
165
+ const listResult = runCommand(psqlCommand, getPsqlListArgs(partialConfig), 8_000);
166
+ const databases = listResult.ok ? parseDatabaseList(listResult.stdout) : [];
167
+ const defaultDatabase = chooseDefaultDatabase(databases);
168
+
169
+ if (!listResult.ok && listResult.stderr.trim()) {
170
+ ctx.ui.notify(`数据库列表读取失败:${listResult.stderr.trim()}`, "warning");
171
+ }
172
+
173
+ if (databases.length > 0) {
174
+ const choice = await ctx.ui.select("请选择数据库", databases);
175
+ return choice ?? defaultDatabase;
176
+ }
177
+
178
+ const database = await askInput(ctx, "请输入数据库名", defaultDatabase);
179
+ return database?.trim() || defaultDatabase;
180
+ }
181
+
182
+ async function collectPsqlConfig(ctx: ExtensionCommandContext): Promise<GeoMindConfig["psql"]> {
183
+ const detectedPsql = findExecutable("psql");
184
+ const commandAnswer = await askInput(ctx, "请输入 psql 命令或完整路径", detectedPsql ?? "psql");
185
+ const command = commandAnswer?.trim() || detectedPsql || "psql";
186
+
187
+ const hostAnswer = await askInput(ctx, "请输入数据库 host,留空表示本机", "localhost");
188
+ const host = hostAnswer?.trim();
189
+
190
+ const portAnswer = await askInput(ctx, "请输入数据库端口,留空使用默认端口", "5432");
191
+ const port = parsePort(portAnswer);
192
+
193
+ const userAnswer = await askInput(ctx, "请输入数据库用户名,留空使用当前系统用户");
194
+ const user = userAnswer?.trim();
195
+
196
+ const partialConfig: NonNullable<GeoMindConfig["psql"]> = {
197
+ command,
198
+ database: "ggai",
199
+ host: host && host !== "localhost" ? host : undefined,
200
+ port,
201
+ user: user || undefined,
202
+ };
203
+
204
+ return {
205
+ ...partialConfig,
206
+ database: await selectDatabase(ctx, command, partialConfig),
207
+ };
208
+ }
209
+
210
+ function writeConfig(cwd: string, scope: "global" | "project", config: GeoMindConfig): string {
211
+ const configPath = getConfigPath(cwd, scope);
212
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
213
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
214
+ return configPath;
215
+ }
216
+
217
+ export default function (pi: ExtensionAPI) {
218
+ pi.registerCommand("setup", {
219
+ description: "Configure GeoMind database connection and QGIS path",
220
+ handler: async (_args, ctx) => {
221
+ if (!ctx.hasUI) {
222
+ ctx.ui.notify("当前模式不支持交互式 setup。请在 TUI 中运行 /setup。", "error");
223
+ return;
224
+ }
225
+
226
+ const saveGlobal = await ctx.ui.confirm(
227
+ "配置保存位置",
228
+ "是否将配置保存为全局?全局配置会在所有目录下生效"
229
+ );
230
+ const scope = saveGlobal ? "global" : "project";
231
+
232
+ const psql = await collectPsqlConfig(ctx);
233
+ const qgisProcess = detectQgisProcess();
234
+ if (!qgisProcess.available) {
235
+ ctx.ui.notify("未检测到 qgis_process。GeoMind 仍可正常启动。", "warning");
236
+ }
237
+
238
+ const configPath = writeConfig(ctx.cwd, scope, {
239
+ psql,
240
+ qgis_process: qgisProcess,
241
+ });
242
+
243
+ ctx.ui.notify(`GeoMind 配置已保存:${configPath}`, "info");
244
+ ctx.ui.notify("配置将在新会话或重启后完整生效。", "info");
245
+ },
246
+ });
247
+ }