@companion-ai/feynman 0.5.0 → 0.5.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.
@@ -5,6 +5,7 @@ thinking: high
5
5
  tools: read, write, edit, bash, grep, find, ls, web_search, fetch_content, get_search_content, feynman_science_database_search, hf_dataset_info, hf_repo_files, hf_repo_read_file
6
6
  output: research.md
7
7
  defaultProgress: true
8
+ async: true
8
9
  ---
9
10
 
10
11
  You are Feynman's evidence-gathering subagent.
@@ -5,6 +5,7 @@ thinking: medium
5
5
  tools: read, bash, grep, find, ls, write, edit, web_search, fetch_content, get_search_content, feynman_science_database_search
6
6
  output: cited.md
7
7
  defaultProgress: true
8
+ async: true
8
9
  ---
9
10
 
10
11
  You are Feynman's verifier agent.
package/AGENTS.md CHANGED
@@ -84,7 +84,7 @@ Never use generic names like `research.md`, `draft.md`, `brief.md`, or `summary.
84
84
 
85
85
  ## Workspace changelog
86
86
 
87
- - `CHANGELOG.md` is a lab notebook, not release notes.
87
+ - `CHANGELOG.md` is a local, untracked lab notebook (ignored by git), not release notes. Release notes live in `RELEASES.md`.
88
88
  - Read `CHANGELOG.md` before resuming substantial work when it exists.
89
89
  - Append concise entries after meaningful progress, failed approaches, major verification results, or new blockers.
90
90
  - Each entry should identify the active slug or objective and end with the next recommended step.
package/README.md CHANGED
@@ -188,7 +188,7 @@ Four bundled research agents, invoked by workflow prompts when decomposition hel
188
188
  - **[Hugging Face Hub](https://huggingface.co/docs/hub/api)** — dataset metadata, split/schema inspection, and small file reads from model, dataset, and Space repos
189
189
  - **Web research** — multi-provider search, explicit proxy routing, bounded GitHub issue/PR documents, raw or question-grounded page retrieval, direct images, external fetched-content caching, stored-page passage lookup, and auditable source text; tools, commands, images, PDFs, and browser cookies remain independently gated
190
190
  - **Session search** — indexed recall across prior research sessions
191
- - **Observability** — opt-out PostHog analytics, logs, and traces for Feynman's own CLI commands
191
+ - **Observability** — opt-out PostHog usage metadata for CLI commands, research workflows, tools, and model calls (see [Telemetry](#telemetry))
192
192
  - **Research execution options** — Docker, plus Modal or RunPod when their CLIs are installed, for explicitly chosen replication, benchmark, or dataset-heavy experiment runs; not service deployment or generic cloud administration
193
193
 
194
194
  ---
@@ -199,6 +199,12 @@ Built on [Pi](https://github.com/badlogic/pi-mono) for the agent runtime, [alpha
199
199
 
200
200
  ---
201
201
 
202
+ ### Telemetry
203
+
204
+ Feynman sends anonymous usage telemetry to PostHog by default and prints a one-time notice on first run. It sends commands, workflow names and outcomes, tool names, model and provider names, token counts, latency, and error flags under a random install ID. It never sends prompts, model output, paper content, file paths, or tool arguments. Set `FEYNMAN_TELEMETRY=off` (or `DO_NOT_TRACK=1`) to disable it; `feynman status` shows the current setting. The full event list is in the [configuration docs](https://feynman.is/docs/getting-started/configuration#telemetry).
205
+
206
+ ---
207
+
202
208
  ### Star History
203
209
 
204
210
  <a href="https://www.star-history.com/?repos=Companion-Inc%2Ffeynman&type=date&legend=top-left">
package/RELEASES.md CHANGED
@@ -6,6 +6,22 @@ GitHub release notes are generated from the matching `## vX.Y.Z` section in this
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## v0.5.2 - 2026-09-23
10
+
11
+ ### Faster, cheaper research runs
12
+
13
+ - OpenAlex exact searches (`openalex_search_works`, citations, references, authors, venues) now return at most the tool's `limit` (default 5, max 20) instead of 50-100 full records; an explicit `max_records` is capped at 50. In live `/lit` runs these results were 100-240 KB per call and drove most of the multi-million-token usage.
14
+ - The `researcher` and `verifier` subagents run in the background by default, where they get Feynman's web and literature tools. Foreground launches of these agents failed and had to be retried.
15
+ - `alpha_ask_paper` now states it takes arXiv and alphaXiv papers only, and points DOI-only papers to `fetch_content` or Europe PMC full text.
16
+
17
+ ## v0.5.1 - 2026-09-23
18
+
19
+ ### Telemetry
20
+
21
+ - **Research runs are now visible in telemetry, without content.** The research extension inside Pi sends `feynman_session_started`, `feynman_workflow_started` and `feynman_workflow_completed` (workflow name such as `deepresearch` or `chat`, status, tool and subagent call counts, whether anything under `outputs/` or `papers/` was written, duration), `feynman_tool_used` (tool name and error flag), and one PostHog LLM analytics `$ai_generation` event per model response (model, provider, token counts, latency, HTTP status, stop reason; the trace ID is the Pi session ID). Prompts, model output, paper content, file paths, and tool arguments are never sent.
22
+ - **First-run notice.** The first interactive run prints a one-time notice on stderr (never in scripts or CI) saying telemetry is on, what it collects, and how to turn it off with `FEYNMAN_TELEMETRY=off`. `feynman status` shows whether telemetry is on.
23
+ - Telemetry stays on by default and follows the existing opt-outs: `FEYNMAN_TELEMETRY=off` or `DO_NOT_TRACK=1` disables the CLI and the Pi runtime events alike.
24
+
9
25
  ## v0.5.0 - 2026-09-23
10
26
 
11
27
  ### Stock Pi runtime
package/dist/cli.js CHANGED
@@ -29,7 +29,7 @@ import { fetchLatestFeynmanVersion, getFeynmanUpgradeLines, isNewerVersion } fro
29
29
  import { runDoctor, runStatus } from "./setup/doctor.js";
30
30
  import { setupPreviewDependencies } from "./setup/preview.js";
31
31
  import { runSetup } from "./setup/setup.js";
32
- import { captureTelemetryEvent, emitTelemetryLog, getCliTelemetryMetadata, initializePostHogTelemetry, shutdownPostHogTelemetry, startTelemetrySpan, telemetryErrorProperties, } from "./telemetry/posthog.js";
32
+ import { captureTelemetryEvent, emitTelemetryLog, getCliTelemetryMetadata, initializePostHogTelemetry, shutdownPostHogTelemetry, startTelemetrySpan, telemetryErrorProperties, telemetryFirstRunNotice, } from "./telemetry/posthog.js";
33
33
  import { ASH, printAsciiHeader, printInfo, printPanel, printSection, RESET, SAGE } from "./ui/terminal.js";
34
34
  import { createModelRuntime } from "./model/registry.js";
35
35
  import { cliCommandSections, formatCliWorkflowUsage, legacyFlags, readPromptSpecs, topLevelCommandNames, } from "../metadata/commands.mjs";
@@ -386,6 +386,9 @@ export async function main() {
386
386
  const appRoot = resolve(here, "..");
387
387
  const feynmanVersion = loadPackageVersion(appRoot).version;
388
388
  initializePostHogTelemetry({ appVersion: feynmanVersion, serviceName: "feynman-cli" });
389
+ const telemetryNotice = telemetryFirstRunNotice();
390
+ if (telemetryNotice)
391
+ process.stderr.write(`${telemetryNotice}\n`);
389
392
  const commandTelemetry = getCliTelemetryMetadata(process.argv.slice(2), { knownCommands: getTelemetryCommandNames(appRoot) });
390
393
  const commandStartedAt = Date.now();
391
394
  const commandSpan = startTelemetrySpan("feynman.cli.command", commandTelemetry);
package/dist/pi/launch.js CHANGED
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { constants } from "node:os";
3
3
  import { buildPiArgs, buildPiEnv, ensureFeynmanCommandShim, ensureFeynmanWorkspaceScaffold, resolvePiCliPath, } from "./runtime.js";
4
4
  import { resolveAllExecutables } from "../system/executables.js";
5
+ import { telemetryFirstRunNotice } from "../telemetry/posthog.js";
5
6
  export function exitCodeFromSignal(signal) {
6
7
  const signalNumber = constants.signals[signal];
7
8
  return typeof signalNumber === "number" ? 128 + signalNumber : 1;
@@ -31,6 +32,10 @@ export async function runPi(options, args, env, stdin = "inherit") {
31
32
  export async function launchPiChat(options) {
32
33
  if (process.stdout.isTTY && options.mode !== "rpc") {
33
34
  process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
35
+ // Clearing the screen erased the first-run notice printed at startup.
36
+ const telemetryNotice = telemetryFirstRunNotice();
37
+ if (telemetryNotice)
38
+ process.stderr.write(`${telemetryNotice}\n`);
34
39
  }
35
40
  if (options.preLaunchNotice) {
36
41
  process.stdout.write(`${options.preLaunchNotice}\n`);
@@ -2,6 +2,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n
2
2
  import { createRequire } from "node:module";
3
3
  import { delimiter, dirname, resolve } from "node:path";
4
4
  import { BROWSER_FALLBACK_PATHS, MERMAID_FALLBACK_PATHS, PANDOC_FALLBACK_PATHS, resolveExecutable, } from "../system/executables.js";
5
+ import { getPostHogChildEnv } from "../telemetry/posthog.js";
5
6
  // Pi packages shipped as Feynman dependencies and loaded from their install
6
7
  // paths through settings.json `packages` (Pi's documented local-path source).
7
8
  export const BUNDLED_PI_PACKAGES = ["pi-subagents", "pi-web-access", "pi-docparser", "pi-btw"];
@@ -132,5 +133,6 @@ export function buildPiEnv(options, executables) {
132
133
  PI_SKIP_VERSION_CHECK: process.env.PI_SKIP_VERSION_CHECK ?? "1",
133
134
  MERMAID_CLI_PATH: mermaidPath,
134
135
  PUPPETEER_EXECUTABLE_PATH: browserPath,
136
+ ...getPostHogChildEnv(),
135
137
  };
136
138
  }
@@ -6,6 +6,7 @@ import { formatPiWebAccessDoctorLines, getPiWebAccessStatus } from "../pi/web-ac
6
6
  import { BROWSER_FALLBACK_PATHS, PANDOC_FALLBACK_PATHS, resolveExecutable } from "../system/executables.js";
7
7
  import { readJson } from "../pi/settings.js";
8
8
  import { validatePiInstallation } from "../pi/runtime.js";
9
+ import { isTelemetryDisabled } from "../telemetry/posthog.js";
9
10
  import { printInfo, printPanel, printSection } from "../ui/terminal.js";
10
11
  import { getCurrentModelSpec } from "../model/commands.js";
11
12
  import { buildModelStatusSnapshotFromRecords, getAvailableModelRecords, getSupportedModelRecords } from "../model/catalog.js";
@@ -79,6 +80,7 @@ export async function runStatus(options) {
79
80
  printInfo(`Web access: pi-web-access (${snapshot.webRouteLabel})`);
80
81
  printInfo(`Service tier: ${getConfiguredServiceTier(options.settingsPath) ?? "not set"}`);
81
82
  printInfo(`Preview: ${snapshot.previewConfigured ? "configured" : "not configured"}`);
83
+ printInfo(`Telemetry: ${isTelemetryDisabled() ? "off" : "on (anonymous usage metadata; set FEYNMAN_TELEMETRY=off to disable)"}`);
82
84
  printSection("Paths");
83
85
  printInfo(`Sessions: ${snapshot.sessionDir}`);
84
86
  printSection("Runtime");
@@ -19,6 +19,11 @@ export const DEFAULT_POSTHOG_PROJECT_TOKEN = "phc_owCZbr7c4mchCuVN5JXA6uBByjbT2k
19
19
  const TELEMETRY_STATE_FILE = "telemetry.json";
20
20
  const TELEMETRY_DISABLED_VALUES = new Set(["0", "false", "no", "off", "disabled"]);
21
21
  const TELEMETRY_KEY_PATTERN = /^[A-Za-z0-9_$./-]+$/;
22
+ export const TELEMETRY_NOTICE = [
23
+ "Attention: Feynman collects anonymous usage telemetry: commands, workflows, tool names, models, token counts, and errors.",
24
+ "It never sends prompts, paper content, file paths, or tool arguments.",
25
+ "To opt out, set FEYNMAN_TELEMETRY=off. Learn more: https://www.feynman.is/docs/getting-started/configuration#telemetry",
26
+ ].join("\n");
22
27
  let posthogClient;
23
28
  let tracerProvider;
24
29
  let loggerProvider;
@@ -26,6 +31,7 @@ let activeConfig;
26
31
  let telemetryInitialized = false;
27
32
  let telemetryStartWarningPrinted = false;
28
33
  let telemetryTransportFailed = false;
34
+ let telemetryNoticeThisProcess;
29
35
  export function createTelemetryTransportCircuitBreaker(onTransportFailure) {
30
36
  let circuitOpen = false;
31
37
  return {
@@ -125,7 +131,7 @@ function disableTelemetryAfterTransportFailure(error) {
125
131
  process.stderr.write(`[feynman] Telemetry disabled for this session after a transport failure (${error instanceof Error ? error.message : "unknown error"}).\n`);
126
132
  }
127
133
  }
128
- function isTelemetryDisabled(env = process.env) {
134
+ export function isTelemetryDisabled(env = process.env) {
129
135
  const setting = env.FEYNMAN_TELEMETRY ?? env.FEYNMAN_POSTHOG_TELEMETRY;
130
136
  return (setting !== undefined && TELEMETRY_DISABLED_VALUES.has(setting.trim().toLowerCase())) || env.DO_NOT_TRACK === "1";
131
137
  }
@@ -156,6 +162,42 @@ function getAnonymousDistinctId(home = getFeynmanHome()) {
156
162
  writeFileSync(statePath, JSON.stringify({ ...state, anonymousId }, null, 2) + "\n", "utf8");
157
163
  return anonymousId;
158
164
  }
165
+ /**
166
+ * Returns the first-run telemetry notice once per Feynman home, and again for
167
+ * the rest of this process so a launch that clears the screen can reprint it.
168
+ * Only interactive terminals get it: scripts and CI (where Windows PowerShell
169
+ * treats any stderr as an error) never see it, and it stays pending for the
170
+ * first interactive run.
171
+ */
172
+ export function telemetryFirstRunNotice(home = getFeynmanHome(), interactive = process.stderr.isTTY === true) {
173
+ if (telemetryNoticeThisProcess || !activeConfig)
174
+ return telemetryNoticeThisProcess;
175
+ if (!interactive)
176
+ return undefined;
177
+ const statePath = resolve(getFeynmanStateDir(home), TELEMETRY_STATE_FILE);
178
+ const state = readTelemetryState(statePath);
179
+ if (state.noticeShown)
180
+ return undefined;
181
+ try {
182
+ mkdirSync(dirname(statePath), { recursive: true });
183
+ writeFileSync(statePath, JSON.stringify({ ...state, noticeShown: true }, null, 2) + "\n", "utf8");
184
+ }
185
+ catch {
186
+ return undefined;
187
+ }
188
+ telemetryNoticeThisProcess = TELEMETRY_NOTICE;
189
+ return telemetryNoticeThisProcess;
190
+ }
191
+ /** Env the Pi child needs for the research extension to send under the same install id. */
192
+ export function getPostHogChildEnv() {
193
+ if (!activeConfig)
194
+ return {};
195
+ return {
196
+ FEYNMAN_POSTHOG_KEY: activeConfig.projectToken,
197
+ FEYNMAN_POSTHOG_HOST: activeConfig.host,
198
+ FEYNMAN_TELEMETRY_DISTINCT_ID: activeConfig.distinctId,
199
+ };
200
+ }
159
201
  export function resolvePostHogTelemetryConfig(options = {}) {
160
202
  const env = options.env ?? process.env;
161
203
  if (isTelemetryDisabled(env))
@@ -441,6 +483,7 @@ export async function shutdownPostHogTelemetry() {
441
483
  activeConfig = undefined;
442
484
  telemetryInitialized = false;
443
485
  telemetryTransportFailed = false;
486
+ telemetryNoticeThisProcess = undefined;
444
487
  await Promise.allSettled([
445
488
  client?.shutdown(3000),
446
489
  traces?.shutdown(),
@@ -86,7 +86,7 @@ export function registerAlphaTools(pi: ExtensionAPI): void {
86
86
  pi.registerTool({
87
87
  name: "alpha_ask_paper",
88
88
  label: "Alpha Ask Paper",
89
- description: "Ask a targeted question about a paper. Uses AI to analyze the PDF and answer.",
89
+ description: "Ask a targeted question about an arXiv or alphaXiv paper. Uses AI to analyze the PDF and answer. DOI-only papers are not supported; read those with fetch_content on the open-access PDF or Europe PMC full text.",
90
90
  parameters: Type.Object({
91
91
  paper: Type.String({ description: "arXiv ID, arXiv URL, or alphaXiv URL." }),
92
92
  question: Type.String({ description: "Question about the paper." }),
@@ -1,4 +1,4 @@
1
- const MAX_EXACT_RECORDS = 500;
1
+ const MAX_EXACT_RECORDS = 50;
2
2
  const REQUEST_TIMEOUT_MS = 25_000;
3
3
  const OPENALEX_BASE = "https://api.openalex.org";
4
4
  const OPEN_ABSTRACT_LICENSES = new Set(["cc-by", "cc-by-sa", "cc0", "public-domain"]);
@@ -461,9 +461,9 @@ async function fetchWorkById(workId: string): Promise<{
461
461
  };
462
462
  }
463
463
 
464
- async function exactWorkSearch(query: string, commandQuery: string): Promise<Record<string, unknown>> {
464
+ async function exactWorkSearch(query: string, commandQuery: string, defaultLimit?: number): Promise<Record<string, unknown>> {
465
465
  const parsed = parseKeyValueQuery(commandQuery);
466
- const maxRecords = safeExactLimit(numberValue(parsed.flags.max_records), 50);
466
+ const maxRecords = safeExactLimit(numberValue(parsed.flags.max_records), defaultLimit ?? 50);
467
467
  const url = endpointPath("/works");
468
468
  if (parsed.text) url.searchParams.set("search", parsed.text);
469
469
  const filters: string[] = [];
@@ -518,12 +518,12 @@ async function exactGetWork(query: string, workId: string): Promise<Record<strin
518
518
  }, "openalex_get_work", query, resolved.endpoints, resolved.credentialStatus);
519
519
  }
520
520
 
521
- async function exactCitations(query: string, commandQuery: string): Promise<Record<string, unknown>> {
521
+ async function exactCitations(query: string, commandQuery: string, defaultLimit?: number): Promise<Record<string, unknown>> {
522
522
  const parsed = parseKeyValueQuery(commandQuery);
523
523
  const workInput = parsed.text;
524
524
  if (!workInput) throw new Error("openalex_citations requires an OpenAlex work id or DOI.");
525
525
  const resolved = await fetchWorkById(workInput);
526
- const maxRecords = safeExactLimit(numberValue(parsed.flags.max_records), 50);
526
+ const maxRecords = safeExactLimit(numberValue(parsed.flags.max_records), defaultLimit ?? 50);
527
527
  const url = endpointPath("/works");
528
528
  url.searchParams.set("filter", `cites:${resolved.workId}`);
529
529
  const sort = exactSearchSort(parsed.flags.sort ?? "cited_by_count", false);
@@ -545,11 +545,11 @@ async function exactCitations(query: string, commandQuery: string): Promise<Reco
545
545
  }, "openalex_citations", query, [...resolved.endpoints, result.endpoint], result.credentialStatus);
546
546
  }
547
547
 
548
- async function exactReferences(query: string, commandQuery: string): Promise<Record<string, unknown>> {
548
+ async function exactReferences(query: string, commandQuery: string, defaultLimit?: number): Promise<Record<string, unknown>> {
549
549
  const parsed = parseKeyValueQuery(commandQuery);
550
550
  if (!parsed.text) throw new Error("openalex_references requires an OpenAlex work id or DOI.");
551
551
  const resolved = await fetchWorkById(parsed.text);
552
- const maxRecords = safeExactLimit(numberValue(parsed.flags.max_records), 100);
552
+ const maxRecords = safeExactLimit(numberValue(parsed.flags.max_records), defaultLimit ?? 100);
553
553
  const referenceIds = arrayValue(resolved.payload.referenced_works).map(shortOpenAlexId).filter((id): id is string => Boolean(id));
554
554
  const selected = referenceIds.slice(0, maxRecords);
555
555
  const endpoints = [...resolved.endpoints];
@@ -581,10 +581,10 @@ async function exactReferences(query: string, commandQuery: string): Promise<Rec
581
581
  }, "openalex_references", query, endpoints, credentialStatus);
582
582
  }
583
583
 
584
- async function exactSearchAuthors(query: string, commandQuery: string): Promise<Record<string, unknown>> {
584
+ async function exactSearchAuthors(query: string, commandQuery: string, defaultLimit?: number): Promise<Record<string, unknown>> {
585
585
  const parsed = parseKeyValueQuery(commandQuery);
586
586
  if (!parsed.text) throw new Error("openalex_search_authors requires a name query.");
587
- const maxRecords = safeExactLimit(numberValue(parsed.flags.max_records), 25);
587
+ const maxRecords = safeExactLimit(numberValue(parsed.flags.max_records), defaultLimit ?? 25);
588
588
  const url = endpointPath("/authors");
589
589
  url.searchParams.set("search", parsed.text);
590
590
  url.searchParams.set("per-page", String(Math.min(maxRecords, 200)));
@@ -625,7 +625,7 @@ async function exactGetAuthor(query: string, commandQuery: string): Promise<Reco
625
625
  }, "openalex_get_author", query, [result.endpoint, works.endpoint], works.credentialStatus);
626
626
  }
627
627
 
628
- async function exactVenueInfo(query: string, commandQuery: string): Promise<Record<string, unknown>> {
628
+ async function exactVenueInfo(query: string, commandQuery: string, defaultLimit?: number): Promise<Record<string, unknown>> {
629
629
  const parsed = parseKeyValueQuery(commandQuery);
630
630
  const venue = parsed.text;
631
631
  if (!venue) throw new Error("openalex_venue_info requires a source id, ISSN, or venue name.");
@@ -640,7 +640,7 @@ async function exactVenueInfo(query: string, commandQuery: string): Promise<Reco
640
640
  records: [row],
641
641
  }, "openalex_venue_info", query, [result.endpoint], result.credentialStatus);
642
642
  }
643
- const maxRecords = safeExactLimit(numberValue(parsed.flags.max_records), 10);
643
+ const maxRecords = safeExactLimit(numberValue(parsed.flags.max_records), defaultLimit ?? 10);
644
644
  const url = endpointPath("/sources");
645
645
  url.searchParams.set("search", venue);
646
646
  url.searchParams.set("per-page", String(Math.min(maxRecords, 200)));
@@ -657,15 +657,15 @@ async function exactVenueInfo(query: string, commandQuery: string): Promise<Reco
657
657
  }, "openalex_venue_info", query, [result.endpoint], result.credentialStatus);
658
658
  }
659
659
 
660
- export async function searchExactOpenAlex(query: string): Promise<Record<string, unknown> | undefined> {
660
+ export async function searchExactOpenAlex(query: string, defaultLimit?: number): Promise<Record<string, unknown> | undefined> {
661
661
  const command = exactCommand(query);
662
662
  if (!command) return undefined;
663
- if (command.name === "openalex_search_works") return exactWorkSearch(query, command.rest);
663
+ if (command.name === "openalex_search_works") return exactWorkSearch(query, command.rest, defaultLimit);
664
664
  if (command.name === "openalex_get_work") return exactGetWork(query, command.rest);
665
- if (command.name === "openalex_citations") return exactCitations(query, command.rest);
666
- if (command.name === "openalex_references") return exactReferences(query, command.rest);
667
- if (command.name === "openalex_search_authors") return exactSearchAuthors(query, command.rest);
665
+ if (command.name === "openalex_citations") return exactCitations(query, command.rest, defaultLimit);
666
+ if (command.name === "openalex_references") return exactReferences(query, command.rest, defaultLimit);
667
+ if (command.name === "openalex_search_authors") return exactSearchAuthors(query, command.rest, defaultLimit);
668
668
  if (command.name === "openalex_get_author") return exactGetAuthor(query, command.rest);
669
- if (command.name === "openalex_venue_info") return exactVenueInfo(query, command.rest);
669
+ if (command.name === "openalex_venue_info") return exactVenueInfo(query, command.rest, defaultLimit);
670
670
  return undefined;
671
671
  }
@@ -354,7 +354,7 @@ async function resolveDoiWork(doiWorkId: string): Promise<{
354
354
  export async function searchOpenAlex(params: SearchParams): Promise<Record<string, unknown>> {
355
355
  const query = cleanQuery(params.query);
356
356
  const limit = safeLimit(params.limit);
357
- const exact = await searchExactOpenAlex(query);
357
+ const exact = await searchExactOpenAlex(query, limit);
358
358
  if (exact) return exact;
359
359
  if (/^rate-limit$/i.test(query)) {
360
360
  if (!openAlexApiKey()) {
@@ -0,0 +1,263 @@
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { PostHog, type PostHogOptions } from "posthog-node";
6
+
7
+ import { readPromptSpecs } from "../../metadata/commands.mjs";
8
+ import { APP_ROOT, FEYNMAN_VERSION } from "./shared.js";
9
+
10
+ // Same opt-out contract as src/telemetry/posthog.ts. The CLI passes the
11
+ // project token, host, and anonymous install id to the Pi child only when its
12
+ // own telemetry is on, so a Pi run outside the CLI sends nothing.
13
+ const TELEMETRY_DISABLED_VALUES = new Set(["0", "false", "no", "off", "disabled"]);
14
+ const FLUSH_TIMEOUT_MS = 2000;
15
+ const OUTPUT_DIRS = ["outputs", "papers"];
16
+ const OUTPUT_SCAN_LIMIT = 5000;
17
+ const SHARED_STATE_KEY = Symbol.for("feynman.research-telemetry");
18
+
19
+ type TelemetryValue = string | number | boolean | undefined;
20
+ export type ResearchTelemetryClient = Pick<PostHog, "capture" | "flush" | "shutdown">;
21
+ export type ResearchTelemetryConfig = { projectToken: string; host: string; distinctId: string };
22
+
23
+ // pi-subagents runs foreground children as in-process sessions that load this
24
+ // extension again with fresh module state, so the owner of the top-level
25
+ // session and the client live on globalThis.
26
+ export type ResearchTelemetrySharedState = {
27
+ client?: ResearchTelemetryClient;
28
+ primary?: object;
29
+ primarySessionId?: string;
30
+ };
31
+
32
+ type WorkflowRun = {
33
+ workflow: string;
34
+ startedAt: number;
35
+ toolCalls: number;
36
+ subagentCalls: number;
37
+ stopReason?: string;
38
+ };
39
+
40
+ export function resolveResearchTelemetryConfig(env: NodeJS.ProcessEnv = process.env): ResearchTelemetryConfig | undefined {
41
+ const setting = env.FEYNMAN_TELEMETRY ?? env.FEYNMAN_POSTHOG_TELEMETRY;
42
+ if ((setting !== undefined && TELEMETRY_DISABLED_VALUES.has(setting.trim().toLowerCase())) || env.DO_NOT_TRACK === "1") {
43
+ return undefined;
44
+ }
45
+ const projectToken = env.FEYNMAN_POSTHOG_KEY?.trim();
46
+ const host = env.FEYNMAN_POSTHOG_HOST?.trim().replace(/\/+$/, "");
47
+ const distinctId = env.FEYNMAN_TELEMETRY_DISTINCT_ID?.trim();
48
+ return projectToken && host && distinctId ? { projectToken, host, distinctId } : undefined;
49
+ }
50
+
51
+ function createPostHogClient(config: ResearchTelemetryConfig): ResearchTelemetryClient {
52
+ // One attempt per send; the first failure drops the rest of this process's
53
+ // sends silently, matching the CLI's circuit breaker.
54
+ let transportFailed = false;
55
+ const fetchOnce: NonNullable<PostHogOptions["fetch"]> = async (url, options) => {
56
+ if (!transportFailed) {
57
+ try {
58
+ const response = await fetch(url, options as RequestInit);
59
+ if (response.status >= 200 && response.status < 400) return response;
60
+ } catch {}
61
+ transportFailed = true;
62
+ }
63
+ return new Response(null, { status: 204 });
64
+ };
65
+ const client = new PostHog(config.projectToken, {
66
+ host: config.host,
67
+ flushAt: 1,
68
+ flushInterval: 0,
69
+ isServer: false,
70
+ disableGeoip: true,
71
+ fetchRetryCount: 0,
72
+ fetch: fetchOnce,
73
+ });
74
+ client.on("error", () => {});
75
+ return client;
76
+ }
77
+
78
+ export function workflowName(text: string, workflows: ReadonlySet<string>): string {
79
+ const name = /^\/([A-Za-z0-9_-]+)(?:\s|$)/.exec(text.trimStart())?.[1];
80
+ return name && workflows.has(name) ? name : "chat";
81
+ }
82
+
83
+ export function wroteResearchOutput(cwd: string, since: number): boolean {
84
+ let scanned = 0;
85
+ for (const dir of OUTPUT_DIRS) {
86
+ let entries: string[];
87
+ try {
88
+ entries = readdirSync(join(cwd, dir), { recursive: true, encoding: "utf8" });
89
+ } catch {
90
+ continue;
91
+ }
92
+ for (const entry of entries) {
93
+ if (++scanned > OUTPUT_SCAN_LIMIT) return false;
94
+ try {
95
+ const stats = statSync(join(cwd, dir, entry));
96
+ if (stats.isFile() && stats.mtimeMs >= since) return true;
97
+ } catch {}
98
+ }
99
+ }
100
+ return false;
101
+ }
102
+
103
+ function stopStatus(stopReason: string | undefined): "completed" | "error" | "aborted" {
104
+ return stopReason === "error" || stopReason === "aborted" ? stopReason : "completed";
105
+ }
106
+
107
+ function readWorkflowNames(): Set<string> {
108
+ try {
109
+ return new Set(readPromptSpecs(APP_ROOT).map((spec) => spec.name));
110
+ } catch {
111
+ return new Set();
112
+ }
113
+ }
114
+
115
+ export function registerResearchTelemetry(
116
+ pi: ExtensionAPI,
117
+ options: {
118
+ env?: NodeJS.ProcessEnv;
119
+ createClient?: (config: ResearchTelemetryConfig) => ResearchTelemetryClient;
120
+ shared?: ResearchTelemetrySharedState;
121
+ now?: () => number;
122
+ } = {},
123
+ ): void {
124
+ const env = options.env ?? process.env;
125
+ const config = resolveResearchTelemetryConfig(env);
126
+ if (!config) return;
127
+ const now = options.now ?? Date.now;
128
+ const globalSlot = globalThis as { [SHARED_STATE_KEY]?: ResearchTelemetrySharedState };
129
+ const shared = options.shared ?? (globalSlot[SHARED_STATE_KEY] ??= {});
130
+ const workflows = readWorkflowNames();
131
+ const owner = {};
132
+ const baseProperties = {
133
+ app_version: FEYNMAN_VERSION,
134
+ node_version: process.versions.node,
135
+ platform: process.platform,
136
+ arch: process.arch,
137
+ telemetry_source: "feynman",
138
+ $process_person_profile: false,
139
+ };
140
+
141
+ let primary = false;
142
+ let traceId: string | undefined;
143
+ let pendingWorkflow: string | undefined;
144
+ let run: WorkflowRun | undefined;
145
+ let requestStartedAt: number | undefined;
146
+ let httpStatus: number | undefined;
147
+
148
+ const capture = (event: string, properties: Record<string, TelemetryValue>) => {
149
+ try {
150
+ shared.client ??= (options.createClient ?? createPostHogClient)(config);
151
+ shared.client.capture({ distinctId: config.distinctId, event, properties: { ...baseProperties, ...properties } });
152
+ } catch {}
153
+ };
154
+
155
+ const finishRun = (cwd: string, status: "completed" | "error" | "aborted") => {
156
+ if (!run) return;
157
+ const finished = run;
158
+ run = undefined;
159
+ capture("feynman_workflow_completed", {
160
+ workflow: finished.workflow,
161
+ status,
162
+ tool_calls: finished.toolCalls,
163
+ subagent_calls: finished.subagentCalls,
164
+ output_written: wroteResearchOutput(cwd, finished.startedAt),
165
+ duration_ms: now() - finished.startedAt,
166
+ });
167
+ };
168
+
169
+ pi.on("session_start", (event, ctx) => {
170
+ const sessionId = ctx.sessionManager.getSessionId();
171
+ primary = env.PI_SUBAGENT_CHILD !== "1" && shared.primary === undefined;
172
+ if (primary) {
173
+ shared.primary = owner;
174
+ shared.primarySessionId = sessionId;
175
+ }
176
+ traceId = primary ? sessionId : (shared.primarySessionId ?? env.PI_SUBAGENT_PARENT_SESSION ?? sessionId);
177
+ if (!primary) return;
178
+ capture("feynman_session_started", {
179
+ reason: event.reason,
180
+ mode: ctx.mode,
181
+ model_provider: ctx.model?.provider,
182
+ model: ctx.model?.id,
183
+ });
184
+ });
185
+
186
+ pi.on("input", (event) => {
187
+ if (primary && !event.streamingBehavior) pendingWorkflow = workflowName(event.text, workflows);
188
+ });
189
+
190
+ pi.on("agent_start", () => {
191
+ if (!primary || run) return;
192
+ run = { workflow: pendingWorkflow ?? "chat", startedAt: now(), toolCalls: 0, subagentCalls: 0 };
193
+ pendingWorkflow = undefined;
194
+ capture("feynman_workflow_started", { workflow: run.workflow });
195
+ });
196
+
197
+ pi.on("tool_execution_end", (event) => {
198
+ capture("feynman_tool_used", { tool: event.toolName, is_error: event.isError, subagent: !primary });
199
+ if (!run) return;
200
+ run.toolCalls += 1;
201
+ if (event.toolName === "subagent") run.subagentCalls += 1;
202
+ });
203
+
204
+ pi.on("before_provider_request", () => {
205
+ requestStartedAt = now();
206
+ });
207
+
208
+ pi.on("after_provider_response", (event) => {
209
+ httpStatus = event.status;
210
+ });
211
+
212
+ pi.on("message_end", (event) => {
213
+ const message = event.message;
214
+ if (message.role !== "assistant") return;
215
+ // PostHog LLM analytics schema, metadata only: no $ai_input or $ai_output_choices.
216
+ capture("$ai_generation", {
217
+ $ai_trace_id: traceId,
218
+ $ai_model: message.model,
219
+ $ai_provider: message.provider,
220
+ $ai_input_tokens: message.usage.input,
221
+ $ai_output_tokens: message.usage.output,
222
+ $ai_cache_read_input_tokens: message.usage.cacheRead,
223
+ $ai_cache_creation_input_tokens: message.usage.cacheWrite,
224
+ // Pi reports input tokens with cache reads and writes already subtracted.
225
+ $ai_cache_reporting_exclusive: true,
226
+ $ai_latency: requestStartedAt === undefined ? undefined : (now() - requestStartedAt) / 1000,
227
+ $ai_http_status: httpStatus,
228
+ $ai_is_error: message.stopReason === "error",
229
+ $ai_stop_reason: message.stopReason,
230
+ subagent: !primary,
231
+ });
232
+ requestStartedAt = undefined;
233
+ httpStatus = undefined;
234
+ if (run) run.stopReason = message.stopReason;
235
+ });
236
+
237
+ pi.on("agent_settled", (_event, ctx) => {
238
+ finishRun(ctx.cwd, stopStatus(run?.stopReason));
239
+ });
240
+
241
+ pi.on("session_shutdown", async (event, ctx) => {
242
+ finishRun(ctx.cwd, "aborted");
243
+ if (shared.primary === owner) {
244
+ shared.primary = undefined;
245
+ shared.primarySessionId = undefined;
246
+ }
247
+ const client = shared.client;
248
+ if (!client) return;
249
+ const quitting = primary && event.reason === "quit";
250
+ if (quitting) shared.client = undefined;
251
+ let timer: NodeJS.Timeout | undefined;
252
+ const timeout = new Promise<void>((resolve) => {
253
+ timer = setTimeout(resolve, FLUSH_TIMEOUT_MS);
254
+ timer.unref?.();
255
+ });
256
+ try {
257
+ await Promise.race([quitting ? client.shutdown(FLUSH_TIMEOUT_MS) : client.flush(), timeout]);
258
+ } catch {
259
+ } finally {
260
+ clearTimeout(timer);
261
+ }
262
+ });
263
+ }
@@ -9,6 +9,7 @@ import { registerHuggingFaceTools } from "./research-tools/huggingface.js";
9
9
  import { registerInitCommand, registerOutputsCommand } from "./research-tools/project.js";
10
10
  import { registerServiceTierControls } from "./research-tools/service-tier.js";
11
11
  import { registerScienceDatabaseTools } from "./research-tools/science-databases.js";
12
+ import { registerResearchTelemetry } from "./research-tools/telemetry.js";
12
13
 
13
14
  export default function researchTools(pi: ExtensionAPI): void {
14
15
  const cache: { agentSummaryPromise?: Promise<{ agents: string[]; chains: string[] }> } = {};
@@ -27,4 +28,5 @@ export default function researchTools(pi: ExtensionAPI): void {
27
28
  registerOutputsCommand(pi);
28
29
  registerServiceTierControls(pi);
29
30
  registerScienceDatabaseTools(pi);
31
+ registerResearchTelemetry(pi);
30
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@companion-ai/feynman",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Research-first CLI agent built on Pi and alphaXiv",
5
5
  "license": "MIT",
6
6
  "type": "module",