@juanhuttemann/pi-deep-research 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juanhuttemann/pi-deep-research",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Fact-checked, cited web research inside Pi: runs the deep-research CLI (drsh) on Pi's own model, with live progress, clarifying questions and plan review",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -120,7 +120,10 @@ export default function deepResearch(pi: ExtensionAPI) {
120
120
  let settings: Setting[] = [];
121
121
  let loadError = "";
122
122
  let choice: ModelChoice = { kind: "pi" };
123
- let lastRun: RunRecord | undefined;
123
+ // lastReport is the last run that wrote a .json, what research_ask reads
124
+ // by default: a run stopped or failed after it leaves none, and must not
125
+ // hide the one before.
126
+ let lastReport: RunRecord | undefined;
124
127
  let commandRun: AbortController | undefined;
125
128
 
126
129
  async function loadSettings(): Promise<Setting[]> {
@@ -132,6 +135,10 @@ export default function deepResearch(pi: ExtensionAPI) {
132
135
  }
133
136
  settings = parseSettings(r.stdout);
134
137
  loadError = "";
138
+ // Wherever drsh first answers (the session's start, /research,
139
+ // /research-doctor, the stand-in), the real tools replace the
140
+ // stand-in: drsh installed mid-session is usable from then on.
141
+ registerTools(settings);
135
142
  } catch (err) {
136
143
  const e = err as NodeJS.ErrnoException;
137
144
  loadError = e.code === "ENOENT" ? `drsh was not found (${bin}).\n${installHelp}` : e.message;
@@ -189,7 +196,7 @@ export default function deepResearch(pi: ExtensionAPI) {
189
196
 
190
197
  function remember(rec: RunRecord | undefined): void {
191
198
  if (!rec) return;
192
- lastRun = rec;
199
+ if (rec.json) lastReport = rec;
193
200
  pi.appendEntry(runEntry, rec);
194
201
  }
195
202
 
@@ -388,9 +395,9 @@ export default function deepResearch(pi: ExtensionAPI) {
388
395
  model: Type.Optional(Type.String({ description: "provider/id of a Pi model to answer with; default as for deep_research." })),
389
396
  }),
390
397
  async execute(_id, params, signal, _onUpdate, ctx) {
391
- const runFile = params.run || lastRun?.json;
398
+ const runFile = params.run || lastReport?.json;
392
399
  if (!runFile) {
393
- throw new Error("no research run in this session yet: pass run with a run's .json path, or run deep_research first");
400
+ throw new Error("no research run in this session has written a report yet: pass run with a run's .json path, or run deep_research first");
394
401
  }
395
402
  const { model, label } = resolveModel(ctx, params.model);
396
403
  const { value: r, bridge } = await withEndpoint(ctx, model, signal, (env) =>
@@ -422,8 +429,7 @@ export default function deepResearch(pi: ExtensionAPI) {
422
429
  description: "Fact-checked, cited web research with the deep-research CLI (drsh). Unavailable until drsh is installed.",
423
430
  parameters: Type.Object({ question: Type.Optional(Type.String()) }),
424
431
  async execute() {
425
- const s = await loadSettings();
426
- registerTools(s);
432
+ await loadSettings();
427
433
  throw new Error("drsh is available now; call deep_research again");
428
434
  },
429
435
  });
@@ -431,14 +437,14 @@ export default function deepResearch(pi: ExtensionAPI) {
431
437
 
432
438
  pi.on("session_start", async (_event, ctx) => {
433
439
  choice = { kind: "pi" };
434
- lastRun = undefined;
440
+ lastReport = undefined;
435
441
  for (const e of ctx.sessionManager.getBranch()) {
436
442
  if (e.type !== "custom") continue;
437
443
  if (e.customType === modelEntry && e.data) choice = e.data as ModelChoice;
438
- if (e.customType === runEntry && e.data) lastRun = e.data as RunRecord;
444
+ if (e.customType === runEntry && (e.data as RunRecord | undefined)?.json) lastReport = e.data as RunRecord;
439
445
  }
440
446
  try {
441
- registerTools(await loadSettings());
447
+ await loadSettings();
442
448
  } catch {
443
449
  registerStandIn();
444
450
  }
@@ -581,6 +587,10 @@ export default function deepResearch(pi: ExtensionAPI) {
581
587
  return;
582
588
  }
583
589
  ctx.ui.setStatus("deep-research", "checking drsh…");
590
+ // The check a user runs after installing drsh: loading its settings
591
+ // brings in the real tools. A drsh that cannot load them is
592
+ // reported by the doctor run below.
593
+ await ensureSettings().catch(() => undefined);
584
594
  try {
585
595
  const { value: r } = await withEndpoint(ctx, model, undefined, (env) => runPlain(bin, ["doctor"], { env }));
586
596
  const verdict = r.code === 0 ? "drsh doctor: ready" : "drsh doctor: something is broken (✗ lines)";
package/src/research.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // to ask (clarify off, instant), so this knows no setting by name.
5
5
 
6
6
  import { existsSync } from "node:fs";
7
- import { readFile, writeFile } from "node:fs/promises";
7
+ import { readdir, readFile, stat, writeFile } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
9
  import { newRunArgs, planRunArgs, resumeArgs, type Answer, type NewRun } from "./args.ts";
10
10
  import { initialState, reduce, type Question } from "./events.ts";
@@ -150,14 +150,46 @@ async function readJSON(path: string | undefined): Promise<unknown> {
150
150
  }
151
151
  }
152
152
 
153
+ // checkpointLeft is the checkpoint a run that ended with no done line left
154
+ // on disk. drsh saves it after each step, as <reports>/*.partial.json, and
155
+ // names it only in the done line, which a killed run never writes: on
156
+ // Windows, where stopping a child process kills it outright, that is every
157
+ // stopped run. Each run has a reports directory of its own, so a checkpoint
158
+ // there is this run's; a resumed run saves to the checkpoint it resumed. A
159
+ // file that is not one drsh can resume from (no question or no plan, as
160
+ // replay.Load refuses) is not offered.
161
+ async function checkpointLeft(c: Call): Promise<string | undefined> {
162
+ let candidates: string[];
163
+ if (c.req.kind === "resume") {
164
+ candidates = [c.req.checkpoint];
165
+ } else {
166
+ const names = await readdir(c.reports).catch(() => [] as string[]);
167
+ candidates = names.filter((n) => n.endsWith(".partial.json")).map((n) => join(c.reports, n));
168
+ }
169
+ const valid: { path: string; mtime: number }[] = [];
170
+ for (const path of candidates) {
171
+ if (resumable(await readJSON(path))) {
172
+ valid.push({ path, mtime: (await stat(path)).mtimeMs });
173
+ }
174
+ }
175
+ return valid.sort((a, b) => b.mtime - a.mtime)[0]?.path;
176
+ }
177
+
178
+ function resumable(raw: unknown): boolean {
179
+ const t = raw as { question?: unknown; plan?: unknown } | null | undefined;
180
+ return typeof t?.question === "string" && t.question.trim() !== "" && Array.isArray(t.plan) && t.plan.length > 0;
181
+ }
182
+
153
183
  // outcome reads what a run's done line says it wrote.
154
- async function outcome(r: JsonlResult, model?: string): Promise<{ o: Outcome; isError: boolean }> {
184
+ async function outcome(c: Call, r: JsonlResult): Promise<{ o: Outcome; isError: boolean }> {
185
+ const model = c.model;
155
186
  if (!r.done) {
156
187
  const status = r.aborted ? "cancelled" : "failed";
157
188
  const detail = r.aborted
158
189
  ? "the run was stopped before it could end cleanly"
159
190
  : `drsh exited (code ${r.code}) without a done line, so it was killed or crashed${r.stderr ? `: ${r.stderr.trim().slice(-1500)}` : ""}`;
160
- return { o: { status, detail, artifacts: [], model }, isError: !r.aborted };
191
+ const checkpoint = await checkpointLeft(c);
192
+ return { o: { status, detail, artifacts: [], model, checkpoint }, isError: !r.aborted };
161
193
  }
162
194
  const artifacts = r.done.artifacts ?? [];
163
195
  const md = artifacts.find((p) => p.endsWith(".md"));
@@ -210,6 +242,6 @@ export async function research(c: Call): Promise<Result> {
210
242
  details.state = initialState();
211
243
  c.hooks.update(details);
212
244
  const r = await stage(c, details, args);
213
- const { o, isError } = await outcome(r, c.model);
245
+ const { o, isError } = await outcome(c, r);
214
246
  return { text: content(o), isError, ...finish(details, o, details.question) };
215
247
  }
package/src/settings.ts CHANGED
@@ -144,7 +144,10 @@ export function parseCommandArgs(settings: Setting[], input: string): CommandArg
144
144
  rest.push(w);
145
145
  continue;
146
146
  }
147
- let [name, v] = w.slice(2).split("=", 2) as [string, string | undefined];
147
+ // Split at the first "=" only: a value may hold one.
148
+ const eq = w.indexOf("=");
149
+ const name = eq < 0 ? w.slice(2) : w.slice(2, eq);
150
+ let v: string | undefined = eq < 0 ? undefined : w.slice(eq + 1);
148
151
  if (v === undefined) {
149
152
  v = words[++i];
150
153
  if (v === undefined) {
@@ -175,7 +178,8 @@ export interface Completion {
175
178
  description?: string;
176
179
  }
177
180
 
178
- // completions offers the option names, then a named option's choices.
181
+ // completions offers the option names, then a named option's choices; an
182
+ // option is named by its key or its CLI flag, as parseCommandArgs reads it.
179
183
  export function completions(settings: Setting[], prefix: string): Completion[] {
180
184
  const words = prefix.split(/\s+/);
181
185
  const last = words[words.length - 1] ?? "";
@@ -183,7 +187,7 @@ export function completions(settings: Setting[], prefix: string): Completion[] {
183
187
  const head = before ? `${before} ` : "";
184
188
  const prev = words[words.length - 2] ?? "";
185
189
  if (prev.startsWith("--")) {
186
- const s = settings.find((x) => x.key === prev.slice(2));
190
+ const s = settings.find((x) => x.key === prev.slice(2) || x.flag === prev.slice(2));
187
191
  if (s) {
188
192
  return s.choices
189
193
  .filter((o) => o.label.startsWith(last))