pi-vault-mind 0.7.4 → 0.8.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/README.md +21 -21
- package/dist/src/agent-queue.d.ts +108 -0
- package/dist/src/agent-queue.js +225 -0
- package/dist/src/auth.d.ts +30 -0
- package/dist/src/auth.js +93 -0
- package/dist/src/auto-index.d.ts +47 -0
- package/dist/src/auto-index.js +122 -0
- package/dist/src/commands.js +265 -4
- package/dist/src/discover-schema.d.ts +27 -0
- package/dist/src/discover-schema.js +255 -0
- package/dist/src/index.js +2 -0
- package/dist/src/intake.d.ts +4 -0
- package/dist/src/intake.js +248 -0
- package/dist/src/server.d.ts +31 -0
- package/dist/src/server.js +561 -4
- package/dist/src/settings-ui.js +11 -11
- package/dist/src/tombstone.d.ts +13 -0
- package/dist/src/tombstone.js +103 -0
- package/dist/src/tools.d.ts +8 -0
- package/dist/src/tools.js +58 -4
- package/dist/src/types.d.ts +47 -0
- package/dist/src/types.js +1 -0
- package/dist/src/utils.d.ts +4 -2
- package/dist/src/utils.js +9 -4
- package/dist/src/watcher.d.ts +7 -1
- package/dist/src/watcher.js +170 -11
- package/dist/test/agent-queue.test.js +204 -0
- package/dist/test/auth.test.js +136 -0
- package/dist/test/auto-indexer.test.js +2 -0
- package/dist/test/dedup.test.js +62 -0
- package/dist/test/discover-schema.test.js +201 -0
- package/dist/test/modal-config.test.js +17 -2
- package/dist/test/queue-events.test.js +146 -0
- package/dist/test/rest-queue.test.js +234 -0
- package/dist/test/rest-setup.test.js +213 -0
- package/dist/test/rest-wiki.test.js +161 -0
- package/dist/test/server-ws.test.js +219 -0
- package/dist/test/tombstone.test.js +233 -0
- package/package.json +6 -2
- package/scripts/e2e-ws.mjs +364 -0
- package/scripts/fetch-modal-token.sh +66 -0
- package/scripts/modal-e2e-smoke.mjs +171 -0
- package/scripts/setup-test-config.sh +66 -0
- package/dist/test/index.test.d.ts +0 -1
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { ensureDir, findConfig } from "./utils.js";
|
|
5
|
+
/** Infer a single JSON value's type. */
|
|
6
|
+
export const inferType = (value) => {
|
|
7
|
+
if (value === null || value === undefined)
|
|
8
|
+
return null;
|
|
9
|
+
switch (typeof value) {
|
|
10
|
+
case "string":
|
|
11
|
+
return "string";
|
|
12
|
+
case "number":
|
|
13
|
+
return Number.isFinite(value) ? "number" : null;
|
|
14
|
+
case "boolean":
|
|
15
|
+
return "boolean";
|
|
16
|
+
case "object":
|
|
17
|
+
return Array.isArray(value) ? "array" : "object";
|
|
18
|
+
default:
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
/** Pick the representative type from a set of observed types. */
|
|
23
|
+
const resolveType = (seen) => {
|
|
24
|
+
if (seen.size === 0)
|
|
25
|
+
return "string";
|
|
26
|
+
if (seen.size === 1)
|
|
27
|
+
return [...seen][0];
|
|
28
|
+
return "mixed";
|
|
29
|
+
};
|
|
30
|
+
/** Make an absolute path relative to `cwd` when it lives underneath it. */
|
|
31
|
+
const shrinkPath = (cwd, p) => {
|
|
32
|
+
const abs = path.resolve(p);
|
|
33
|
+
const root = path.resolve(cwd);
|
|
34
|
+
if (abs === root)
|
|
35
|
+
return ".";
|
|
36
|
+
if (abs.startsWith(`${root}${path.sep}`)) {
|
|
37
|
+
return path.relative(root, abs);
|
|
38
|
+
}
|
|
39
|
+
return abs;
|
|
40
|
+
};
|
|
41
|
+
/** Read the first `sample` JSON lines from a .jsonl file and infer schema. */
|
|
42
|
+
export const discoverSchemaFromFile = (filePath, sample = 100) => {
|
|
43
|
+
if (!fs.existsSync(filePath)) {
|
|
44
|
+
throw new Error(`File not found: ${filePath}`);
|
|
45
|
+
}
|
|
46
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
47
|
+
const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
48
|
+
const rowCount = lines.length;
|
|
49
|
+
const sampled = Math.min(sample, rowCount);
|
|
50
|
+
const typeSets = new Map();
|
|
51
|
+
for (let i = 0; i < sampled; i++) {
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(lines[i]);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const record = parsed;
|
|
63
|
+
for (const [key, value] of Object.entries(record)) {
|
|
64
|
+
const type = inferType(value);
|
|
65
|
+
if (type) {
|
|
66
|
+
let set = typeSets.get(key);
|
|
67
|
+
if (!set) {
|
|
68
|
+
set = new Set();
|
|
69
|
+
typeSets.set(key, set);
|
|
70
|
+
}
|
|
71
|
+
set.add(type);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const allKeys = [...typeSets.keys()];
|
|
76
|
+
const types = {};
|
|
77
|
+
for (const key of allKeys) {
|
|
78
|
+
const set = typeSets.get(key);
|
|
79
|
+
types[key] = set ? resolveType(set) : "string";
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
schema: allKeys,
|
|
83
|
+
types,
|
|
84
|
+
rowCount,
|
|
85
|
+
sampled,
|
|
86
|
+
allKeys,
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
/** Read the mutable project config object, or an empty object if none exists. */
|
|
90
|
+
const readProjectConfig = (cwd) => {
|
|
91
|
+
const cfgPath = findConfig(cwd).project || path.join(cwd, "pi-vault-mind.config.json");
|
|
92
|
+
if (!fs.existsSync(cfgPath))
|
|
93
|
+
return {};
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
/** Persist the project config with standard formatting. */
|
|
102
|
+
const writeProjectConfig = (cwd, obj) => {
|
|
103
|
+
const cfgPath = findConfig(cwd).project || path.join(cwd, "pi-vault-mind.config.json");
|
|
104
|
+
ensureDir(cfgPath);
|
|
105
|
+
fs.writeFileSync(cfgPath, `${JSON.stringify(obj, null, 2)}\n`, "utf-8");
|
|
106
|
+
return cfgPath;
|
|
107
|
+
};
|
|
108
|
+
/** Register (or update) a collection in the project config. */
|
|
109
|
+
export const writeCollectionToConfig = (cwd, name, def) => {
|
|
110
|
+
const cfg = readProjectConfig(cwd);
|
|
111
|
+
const collections = (cfg.collections || {});
|
|
112
|
+
cfg.collections = {
|
|
113
|
+
...collections,
|
|
114
|
+
[name]: {
|
|
115
|
+
path: def.path,
|
|
116
|
+
schema: def.schema,
|
|
117
|
+
...(def.dedupField ? { dedupField: def.dedupField } : {}),
|
|
118
|
+
...(def.dedupMode ? { dedupMode: def.dedupMode } : {}),
|
|
119
|
+
...(def.dedupThreshold !== undefined ? { dedupThreshold: def.dedupThreshold } : {}),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
return writeProjectConfig(cwd, cfg);
|
|
123
|
+
};
|
|
124
|
+
/** Run the full discover_schema flow and update config. */
|
|
125
|
+
export const runDiscoverSchema = (cwd, file, name, sample = 100) => {
|
|
126
|
+
const resolvedFile = path.isAbsolute(file) ? file : path.join(cwd, file);
|
|
127
|
+
const parsed = discoverSchemaFromFile(resolvedFile, sample);
|
|
128
|
+
const collectionName = name?.trim() ||
|
|
129
|
+
path.basename(resolvedFile, path.extname(resolvedFile)).replace(/\W+/g, "_") ||
|
|
130
|
+
"discovered";
|
|
131
|
+
const collectionPath = shrinkPath(cwd, resolvedFile);
|
|
132
|
+
const def = {
|
|
133
|
+
path: collectionPath,
|
|
134
|
+
schema: parsed.schema,
|
|
135
|
+
};
|
|
136
|
+
writeCollectionToConfig(cwd, collectionName, def);
|
|
137
|
+
return {
|
|
138
|
+
collectionName,
|
|
139
|
+
path: collectionPath,
|
|
140
|
+
schema: parsed.schema,
|
|
141
|
+
types: parsed.types,
|
|
142
|
+
rowCount: parsed.rowCount,
|
|
143
|
+
sampled: parsed.sampled,
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
/** Register the `discover_schema` Pi tool. */
|
|
147
|
+
export const registerDiscoverSchemaTool = (pi) => {
|
|
148
|
+
pi.registerTool({
|
|
149
|
+
name: "discover_schema",
|
|
150
|
+
label: "Discover JSONL Schema",
|
|
151
|
+
description: "Infer the schema of an unconfigured .jsonl file by sampling its first N rows and register it as a wiki collection.",
|
|
152
|
+
promptSnippet: 'discover_schema(file="data/events.jsonl", name="events", sample=100)',
|
|
153
|
+
promptGuidelines: [
|
|
154
|
+
"Use when you have a .jsonl file that is not yet mapped in pi-vault-mind.config.json.",
|
|
155
|
+
"The tool reads the first N rows, infers field names and JSON types, and writes a CollectionDef to the project config.",
|
|
156
|
+
],
|
|
157
|
+
parameters: Type.Object({
|
|
158
|
+
file: Type.String({
|
|
159
|
+
description: "Path to the .jsonl file (absolute or relative to the project root)",
|
|
160
|
+
}),
|
|
161
|
+
name: Type.Optional(Type.String({ description: "Collection name to register (defaults to the file basename)" })),
|
|
162
|
+
sample: Type.Optional(Type.Number({
|
|
163
|
+
description: "Number of rows to sample for schema inference (default: 100)",
|
|
164
|
+
default: 100,
|
|
165
|
+
})),
|
|
166
|
+
}),
|
|
167
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
168
|
+
try {
|
|
169
|
+
const result = runDiscoverSchema(ctx.cwd, params.file, params.name, params.sample ?? 100);
|
|
170
|
+
const report = [
|
|
171
|
+
`Discovered schema for "${result.collectionName}"`,
|
|
172
|
+
`Path: ${result.path}`,
|
|
173
|
+
`Rows read: ${result.rowCount}`,
|
|
174
|
+
`Sampled: ${result.sampled}`,
|
|
175
|
+
"",
|
|
176
|
+
"Schema:",
|
|
177
|
+
...result.schema.map((field) => ` - ${field}: ${result.types[field]}`),
|
|
178
|
+
].join("\n");
|
|
179
|
+
return { content: [{ type: "text", text: report }], details: { result } };
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
return { content: [{ type: "text", text: `Error: ${error.message}` }], details: {} };
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
/** Parse CLI-style arguments for /wiki discover-schema. */
|
|
188
|
+
const parseDiscoverSchemaArgs = (args) => {
|
|
189
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
190
|
+
if (tokens.length === 0) {
|
|
191
|
+
return { error: "Usage: /wiki discover-schema <file> [--name <collection>] [--sample <n>]" };
|
|
192
|
+
}
|
|
193
|
+
let file;
|
|
194
|
+
let name;
|
|
195
|
+
let sample;
|
|
196
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
197
|
+
const token = tokens[i];
|
|
198
|
+
if (token === "--name") {
|
|
199
|
+
name = tokens[++i];
|
|
200
|
+
if (!name)
|
|
201
|
+
return { error: "--name requires a value" };
|
|
202
|
+
}
|
|
203
|
+
else if (token === "--sample") {
|
|
204
|
+
const raw = tokens[++i];
|
|
205
|
+
if (!raw)
|
|
206
|
+
return { error: "--sample requires a value" };
|
|
207
|
+
const n = Number.parseInt(raw, 10);
|
|
208
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
209
|
+
return { error: `--sample must be a positive integer, got "${raw}"` };
|
|
210
|
+
sample = n;
|
|
211
|
+
}
|
|
212
|
+
else if (!token.startsWith("--")) {
|
|
213
|
+
if (file)
|
|
214
|
+
return { error: `Unexpected extra argument: ${token}` };
|
|
215
|
+
file = token;
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
return { error: `Unknown flag: ${token}` };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!file) {
|
|
222
|
+
return { error: "Missing .jsonl file path" };
|
|
223
|
+
}
|
|
224
|
+
return { file, name, sample };
|
|
225
|
+
};
|
|
226
|
+
/** Handler for the /wiki discover-schema slash command. */
|
|
227
|
+
export const handleDiscoverSchema = async (args, ctx) => {
|
|
228
|
+
const parsed = parseDiscoverSchemaArgs(args);
|
|
229
|
+
if (parsed.error) {
|
|
230
|
+
ctx.ui.notify(parsed.error, "error");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const { file, name, sample } = parsed;
|
|
234
|
+
if (!file) {
|
|
235
|
+
ctx.ui.notify("Missing .jsonl file path", "error");
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
const result = runDiscoverSchema(ctx.cwd, file, name, sample ?? 100);
|
|
240
|
+
const lines = [
|
|
241
|
+
`**Discovered schema: "${result.collectionName}"**`,
|
|
242
|
+
"",
|
|
243
|
+
`Path: ${result.path}`,
|
|
244
|
+
`Rows read: ${result.rowCount}`,
|
|
245
|
+
`Sampled: ${result.sampled}`,
|
|
246
|
+
"",
|
|
247
|
+
"Schema:",
|
|
248
|
+
...result.schema.map((field) => ` • ${field}: ${result.types[field]}`),
|
|
249
|
+
];
|
|
250
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
ctx.ui.notify(`discover-schema failed: ${err.message}`, "error");
|
|
254
|
+
}
|
|
255
|
+
};
|
package/dist/src/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import { startAutoSync } from "./autosync.js";
|
|
4
4
|
import { registerCommands, selectActiveCollection, serverState, watcherState } from "./commands.js";
|
|
5
5
|
import { handleBeforeAgentStart, handleTurnEnd } from "./events.js";
|
|
6
|
+
import { registerIntakeTool } from "./intake.js";
|
|
6
7
|
import { startServer, stopServer } from "./server.js";
|
|
7
8
|
import { registerTools } from "./tools.js";
|
|
8
9
|
import { EXT_ROOT } from "./utils.js";
|
|
@@ -30,6 +31,7 @@ export default function (pi) {
|
|
|
30
31
|
registerCommands(pi);
|
|
31
32
|
/* register tools */
|
|
32
33
|
registerTools(pi);
|
|
34
|
+
registerIntakeTool(pi);
|
|
33
35
|
/* before_agent_start: dynamic injectors */
|
|
34
36
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
35
37
|
return handleBeforeAgentStart(pi, event, ctx);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AskIntakeInput, AskIntakeResult } from "./types.js";
|
|
3
|
+
export declare const runAskIntake: (ctx: ExtensionContext, params: AskIntakeInput) => Promise<AskIntakeResult>;
|
|
4
|
+
export declare const registerIntakeTool: (pi: ExtensionAPI) => void;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { ensureDir, expandHome, loadConfig } from "./utils.js";
|
|
5
|
+
/**
|
|
6
|
+
* ask_intake — interactive questionnaire tool.
|
|
7
|
+
*
|
|
8
|
+
* Implementation notes (answers to the ambiguities in docs/plans/intake-tui.md):
|
|
9
|
+
*
|
|
10
|
+
* 1. Multi-select UI primitive: ctx.ui.custom() is unavailable in RPC mode
|
|
11
|
+
* (returns undefined), so we build multi-select by looping ctx.ui.select()
|
|
12
|
+
* with a control item ("✓ Done") and a "Cancel" item. This works in both
|
|
13
|
+
* TUI and RPC and gives us an explicit cancel signal.
|
|
14
|
+
* 2. Vault path resolution: loadConfig() already expands wiki.vaults.default.path
|
|
15
|
+
* (see src/utils.ts expandVaultPaths). We use cfg.wiki.vaults.default.path.
|
|
16
|
+
* 3. Atomic vault write: VaultWriter already ensures Agent/Inbox/ exists, so we
|
|
17
|
+
* reuse it when available; fall back to mkdirSync + writeFileSync.
|
|
18
|
+
* 4. Progress indicator: ctx.ui.setStatus("ask_intake", "Question 2 of 7...")
|
|
19
|
+
* works in both TUI and RPC (fire-and-forget in RPC).
|
|
20
|
+
* 5. Build verification: pnpm build.
|
|
21
|
+
*/
|
|
22
|
+
const OptionSchema = Type.Object({
|
|
23
|
+
label: Type.String({ description: "Display label for the option" }),
|
|
24
|
+
description: Type.Optional(Type.String({ description: "Description shown below or beside the label" })),
|
|
25
|
+
});
|
|
26
|
+
const QuestionSchema = Type.Object({
|
|
27
|
+
question: Type.String({ description: "Full prompt shown to the user" }),
|
|
28
|
+
header: Type.String({ description: "Short label used in the progress indicator" }),
|
|
29
|
+
options: Type.Optional(Type.Array(OptionSchema, { description: "Present = select; absent = free text" })),
|
|
30
|
+
multi_select: Type.Boolean({ description: "true = multi-select, false = single-select" }),
|
|
31
|
+
});
|
|
32
|
+
const AskIntakeParameters = Type.Object({
|
|
33
|
+
questions: Type.Array(QuestionSchema, { description: "At least one question" }),
|
|
34
|
+
persist: Type.Optional(Type.Union([Type.Literal("none"), Type.Literal("ledger"), Type.Literal("vault"), Type.Literal("both")], { description: "Where to persist answers (default: none)" })),
|
|
35
|
+
collection: Type.Optional(Type.String({ description: "Ledger collection (default: main)" })),
|
|
36
|
+
});
|
|
37
|
+
const CONTROL_DONE = "✓ Done";
|
|
38
|
+
const CONTROL_CANCEL = "✗ Cancel";
|
|
39
|
+
/** Render a single option label, optionally with its description. */
|
|
40
|
+
const formatOption = (opt) => {
|
|
41
|
+
if (opt.description)
|
|
42
|
+
return `${opt.label} — ${opt.description}`;
|
|
43
|
+
return opt.label;
|
|
44
|
+
};
|
|
45
|
+
/** Recover the original option label from a formatted select value. */
|
|
46
|
+
const findOptionLabel = (value, options) => {
|
|
47
|
+
for (const opt of options) {
|
|
48
|
+
if (formatOption(opt) === value)
|
|
49
|
+
return opt.label;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
};
|
|
53
|
+
/** Random suffix for generated IDs. */
|
|
54
|
+
const randomSuffix = () => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
55
|
+
export const runAskIntake = async (ctx, params) => {
|
|
56
|
+
const persist = params.persist ?? "none";
|
|
57
|
+
const collection = params.collection ?? "main";
|
|
58
|
+
const answers = {};
|
|
59
|
+
let cancelled = false;
|
|
60
|
+
try {
|
|
61
|
+
for (let i = 0; i < params.questions.length; i++) {
|
|
62
|
+
if (cancelled)
|
|
63
|
+
break;
|
|
64
|
+
const q = params.questions[i];
|
|
65
|
+
ctx.ui.setStatus("ask_intake", `Question ${i + 1} of ${params.questions.length}: ${q.header}`);
|
|
66
|
+
if (q.options && q.options.length > 0) {
|
|
67
|
+
if (q.multi_select) {
|
|
68
|
+
const selected = [];
|
|
69
|
+
let choosing = true;
|
|
70
|
+
while (choosing) {
|
|
71
|
+
const remaining = q.options
|
|
72
|
+
.filter((opt) => !selected.includes(opt.label))
|
|
73
|
+
.map((opt) => formatOption(opt));
|
|
74
|
+
const items = [...remaining, CONTROL_DONE, CONTROL_CANCEL];
|
|
75
|
+
const pick = await ctx.ui.select(`${q.question}\n(Selected: ${selected.length ? selected.join(", ") : "none"})`, items);
|
|
76
|
+
if (pick === undefined || pick === CONTROL_CANCEL) {
|
|
77
|
+
cancelled = true;
|
|
78
|
+
choosing = false;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
if (pick === CONTROL_DONE) {
|
|
82
|
+
choosing = false;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
const label = findOptionLabel(pick, q.options) ?? pick;
|
|
86
|
+
if (!selected.includes(label))
|
|
87
|
+
selected.push(label);
|
|
88
|
+
}
|
|
89
|
+
answers[q.question] = selected;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const items = q.options.map((opt) => formatOption(opt));
|
|
93
|
+
const pick = await ctx.ui.select(q.question, items);
|
|
94
|
+
if (pick === undefined) {
|
|
95
|
+
cancelled = true;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
answers[q.question] = findOptionLabel(pick, q.options) ?? pick;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const value = await ctx.ui.input(q.question);
|
|
104
|
+
if (value === undefined) {
|
|
105
|
+
cancelled = true;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
answers[q.question] = value;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
ctx.ui.setStatus("ask_intake", undefined);
|
|
115
|
+
}
|
|
116
|
+
const partial = cancelled && Object.keys(answers).length > 0;
|
|
117
|
+
const result = { answers, cancelled, partial };
|
|
118
|
+
if (!cancelled && (persist === "ledger" || persist === "both")) {
|
|
119
|
+
const ledgerResult = persistToLedger(ctx, answers, collection);
|
|
120
|
+
if (ledgerResult)
|
|
121
|
+
result.ledgerEntry = ledgerResult;
|
|
122
|
+
}
|
|
123
|
+
if (!cancelled && (persist === "vault" || persist === "both")) {
|
|
124
|
+
const vaultResult = persistToVault(ctx, answers, params.questions);
|
|
125
|
+
if (vaultResult)
|
|
126
|
+
result.vaultEntry = vaultResult;
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
};
|
|
130
|
+
export const registerIntakeTool = (pi) => {
|
|
131
|
+
pi.registerTool({
|
|
132
|
+
name: "ask_intake",
|
|
133
|
+
label: "Ask Intake",
|
|
134
|
+
description: "Ask the user a structured questionnaire (single or multi-select, free text) and optionally persist answers to the wiki ledger, the Obsidian vault, or both.",
|
|
135
|
+
promptSnippet: 'ask_intake(questions=[{question:"...", header:"...", options:[{label:"...", description:"..."}], multi_select:false}], persist="none")',
|
|
136
|
+
promptGuidelines: [
|
|
137
|
+
"Use ask_intake when you need structured answers from the user and want them persisted.",
|
|
138
|
+
"persist='ledger' appends a row to the JSONL collection; persist='vault' writes a markdown note under Agent/Inbox/.",
|
|
139
|
+
"Answers are returned keyed by the question string.",
|
|
140
|
+
],
|
|
141
|
+
parameters: AskIntakeParameters,
|
|
142
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
143
|
+
if (!ctx.hasUI) {
|
|
144
|
+
return {
|
|
145
|
+
content: [{ type: "text", text: "ask_intake requires an interactive UI session." }],
|
|
146
|
+
details: {
|
|
147
|
+
answers: {},
|
|
148
|
+
cancelled: true,
|
|
149
|
+
partial: true,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (!params.questions || params.questions.length === 0) {
|
|
154
|
+
return {
|
|
155
|
+
content: [{ type: "text", text: "ask_intake requires at least one question." }],
|
|
156
|
+
details: {
|
|
157
|
+
answers: {},
|
|
158
|
+
cancelled: true,
|
|
159
|
+
partial: true,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const result = await runAskIntake(ctx, params);
|
|
164
|
+
const summary = result.cancelled
|
|
165
|
+
? `Intake cancelled. Answered ${Object.keys(result.answers).length}/${params.questions.length} question(s).`
|
|
166
|
+
: `Intake complete. Answered ${params.questions.length} question(s).`;
|
|
167
|
+
return {
|
|
168
|
+
content: [{ type: "text", text: summary }],
|
|
169
|
+
details: result,
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
const persistToLedger = (ctx, answers, collection) => {
|
|
175
|
+
const cfg = loadConfig(ctx.cwd);
|
|
176
|
+
const def = cfg.collections[collection];
|
|
177
|
+
if (!def) {
|
|
178
|
+
ctx.ui.notify(`ask_intake: unknown collection "${collection}" — skipping ledger persist.`, "warning");
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const id = `intake-${new Date().toISOString()}-${randomSuffix()}`;
|
|
182
|
+
const entry = {
|
|
183
|
+
id,
|
|
184
|
+
domain: "intake",
|
|
185
|
+
source: "intake_tui",
|
|
186
|
+
fact: JSON.stringify(answers),
|
|
187
|
+
tag: "intake",
|
|
188
|
+
artifact: "",
|
|
189
|
+
};
|
|
190
|
+
try {
|
|
191
|
+
ensureDir(def.path);
|
|
192
|
+
fs.appendFileSync(def.path, `${JSON.stringify(entry)}\n`, "utf-8");
|
|
193
|
+
return { id, path: def.path, tag: "intake" };
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
ctx.ui.notify(`ask_intake: ledger write failed: ${err.message}`, "error");
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
const persistToVault = (ctx, answers, questions) => {
|
|
201
|
+
const cfg = loadConfig(ctx.cwd);
|
|
202
|
+
const vaultCfg = cfg.wiki.vaults?.default;
|
|
203
|
+
if (!vaultCfg?.path) {
|
|
204
|
+
ctx.ui.notify("ask_intake: no default vault configured — skipping vault persist.", "warning");
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const vaultPath = expandHome(vaultCfg.path);
|
|
208
|
+
const inbox = path.join(vaultPath, "Agent", "Inbox");
|
|
209
|
+
const filename = `intake-${new Date().toISOString()}.md`;
|
|
210
|
+
const filepath = path.join(inbox, filename);
|
|
211
|
+
const lines = [
|
|
212
|
+
"---",
|
|
213
|
+
`created: ${new Date().toISOString()}`,
|
|
214
|
+
"source: intake_tui",
|
|
215
|
+
"tag: intake",
|
|
216
|
+
"---",
|
|
217
|
+
"",
|
|
218
|
+
"# Intake Answers",
|
|
219
|
+
"",
|
|
220
|
+
];
|
|
221
|
+
for (const q of questions) {
|
|
222
|
+
const answer = answers[q.question];
|
|
223
|
+
lines.push(`## ${q.header || q.question}`);
|
|
224
|
+
lines.push("");
|
|
225
|
+
if (answer === undefined) {
|
|
226
|
+
lines.push("_Not answered_");
|
|
227
|
+
}
|
|
228
|
+
else if (Array.isArray(answer)) {
|
|
229
|
+
for (const a of answer) {
|
|
230
|
+
lines.push(`- ${a}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
lines.push(`- ${answer}`);
|
|
235
|
+
}
|
|
236
|
+
lines.push("");
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
if (!fs.existsSync(inbox))
|
|
240
|
+
fs.mkdirSync(inbox, { recursive: true });
|
|
241
|
+
fs.writeFileSync(filepath, lines.join("\n"), "utf-8");
|
|
242
|
+
return { path: filepath };
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
ctx.ui.notify(`ask_intake: vault write failed: ${err.message}`, "error");
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
};
|
package/dist/src/server.d.ts
CHANGED
|
@@ -5,21 +5,52 @@
|
|
|
5
5
|
* GET /vault-mind/status → { running, dispatches, vaults, uptime }
|
|
6
6
|
* POST /vault-mind/scan → scan a file for @agent markers { file }
|
|
7
7
|
* POST /vault-mind/dispatch → fire a manual dispatch
|
|
8
|
+
* POST /vault-mind/context → push active editor context from Obsidian { filePath, selection, cursor }
|
|
9
|
+
* GET /vault-mind/context → latest editor context (file, cursor, selection) for pi agents
|
|
8
10
|
*
|
|
9
11
|
* Binds 127.0.0.1 only — no network exposure. Port defaults to 11435,
|
|
10
12
|
* configurable via wiki.httpPort in pi-vault-mind.config.json.
|
|
11
13
|
*
|
|
12
14
|
* Used by the obsidian-shellcommands plugin to notify pi of file saves,
|
|
13
15
|
* replacing fs.watch reliance with explicit push notifications.
|
|
16
|
+
*
|
|
17
|
+
* New Stage-1 routes (auth-gated by `PVM_API_TOKEN`):
|
|
18
|
+
* GET /wiki/status, POST /wiki/init, POST /wiki/setup, POST /server/start,
|
|
19
|
+
* GET /vault-mind/config, GET /agent/queue, GET /agent/jobs/:id,
|
|
20
|
+
* POST /agent/jobs/:id/retry, POST /agent/jobs/:id/cancel,
|
|
21
|
+
* POST /wiki/search, POST /wiki/append, WS /agent/stream.
|
|
14
22
|
*/
|
|
15
23
|
import * as http from "node:http";
|
|
16
24
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { WebSocketServer } from "ws";
|
|
17
26
|
import type { WatcherState } from "./watcher.js";
|
|
18
27
|
export interface ServerState {
|
|
19
28
|
server: http.Server | null;
|
|
29
|
+
/** WebSocket server for live queue streaming. */
|
|
30
|
+
wss: WebSocketServer | null;
|
|
20
31
|
port: number;
|
|
21
32
|
startTime: number;
|
|
33
|
+
/** The pi ExtensionAPI, stored so REST routes can call pi.sendUserMessage etc. */
|
|
34
|
+
pi?: ExtensionAPI;
|
|
35
|
+
/** The watcher state, stored so REST routes can read queue / status. */
|
|
36
|
+
watcherState?: WatcherState;
|
|
37
|
+
/** Latest editor context pushed from Obsidian (active file, cursor, selection). */
|
|
38
|
+
latestEditorContext?: {
|
|
39
|
+
filePath: string | null;
|
|
40
|
+
selection: string | null;
|
|
41
|
+
cursor: number | null;
|
|
42
|
+
};
|
|
43
|
+
/** The vault path this server is serving (default vault from global config, or cwd). */
|
|
44
|
+
vaultPath?: string;
|
|
22
45
|
}
|
|
23
46
|
export declare function createServerState(port?: number): ServerState;
|
|
24
47
|
export declare function startServer(pi: ExtensionAPI, serverState: ServerState, watcherState: WatcherState): void;
|
|
25
48
|
export declare function stopServer(serverState: ServerState): void;
|
|
49
|
+
/**
|
|
50
|
+
* Wrap a request handler with bearer-token auth. Use this for every
|
|
51
|
+
* new Stage-1 route so auth is consistent and testable in one place.
|
|
52
|
+
* The handler is only invoked if `authoriseRequest` passes.
|
|
53
|
+
*/
|
|
54
|
+
export declare const withAuth: (req: http.IncomingMessage, res: http.ServerResponse, handler: () => void) => void;
|
|
55
|
+
/** Read the full request body as UTF-8. Already used by handleScan; exposed for new routes. */
|
|
56
|
+
export declare const readJsonBody: (req: http.IncomingMessage) => Promise<unknown>;
|