@byterover/claude-plugin 1.0.0 → 1.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,124 @@
1
+ import { join } from "node:path";
2
+ import { select } from "@inquirer/prompts";
3
+ import pc from "picocolors";
4
+ import { buildHookCommand, isBridgeHook, resolveBridgeExecutable, } from "../bridge-command.js";
5
+ import { getClaudeConfigHome } from "../memory-path.js";
6
+ import { backupSettings, readSettingsRaw, writeSettingsRaw, } from "../schemas/cc-settings.js";
7
+ /**
8
+ * Inspect a settings.json object and classify the existing `statusLine` entry:
9
+ * - "absent": no `statusLine` field
10
+ * - "ours": `statusLine.command` carries our bridge marker
11
+ * - "foreign": some other statusLine is configured (or malformed)
12
+ */
13
+ export function diagnoseStatuslineConflict(settings) {
14
+ const sl = settings.statusLine;
15
+ if (sl === undefined)
16
+ return "absent";
17
+ if (typeof sl === "object" && sl !== null) {
18
+ const obj = sl;
19
+ if (typeof obj.command === "string" && isBridgeHook({ command: obj.command })) {
20
+ return "ours";
21
+ }
22
+ }
23
+ return "foreign";
24
+ }
25
+ /** Re-run cadence (seconds) on top of Claude Code's event-driven triggers. */
26
+ const REFRESH_INTERVAL_SECONDS = 5;
27
+ /**
28
+ * Set our `statusLine` entry on a settings object. Padding is intentionally
29
+ * omitted so Claude Code's default applies. `refreshInterval` is set so the
30
+ * line reflects daemon-side state changes (curate, dream) that happen while
31
+ * the assistant is mid-tool-call — Claude Code's event triggers don't fire
32
+ * during those windows. Mutates and returns `settings`.
33
+ */
34
+ export function setStatuslineEntry(settings, command) {
35
+ settings.statusLine = {
36
+ type: "command",
37
+ command,
38
+ refreshInterval: REFRESH_INTERVAL_SECONDS,
39
+ };
40
+ return settings;
41
+ }
42
+ export function registerInstallStatuslineCommand(program) {
43
+ program
44
+ .command("install-statusline")
45
+ .description("Install the byterover status line into Claude Code (opt-in)")
46
+ .option("--dry-run", "Show what would be written without modifying files")
47
+ .option("--force", "Overwrite an existing foreign statusLine without prompting")
48
+ .option("--settings-path <path>", "Override path to Claude Code settings.json")
49
+ .action(async (opts) => {
50
+ try {
51
+ const exe = resolveBridgeExecutable();
52
+ console.log(pc.dim(`Resolved executable: ${exe}`));
53
+ const settingsPath = opts.settingsPath ?? join(getClaudeConfigHome(), "settings.json");
54
+ const settings = readSettingsRaw(settingsPath);
55
+ const state = diagnoseStatuslineConflict(settings);
56
+ const command = buildHookCommand("status");
57
+ if (state === "ours") {
58
+ // Compare current entry to what we'd write. If identical, no-op.
59
+ // If our shape has drifted (e.g. plugin upgrade added refreshInterval),
60
+ // upgrade in place — backup + rewrite.
61
+ const expected = { type: "command", command, refreshInterval: REFRESH_INTERVAL_SECONDS };
62
+ if (JSON.stringify(settings.statusLine) === JSON.stringify(expected)) {
63
+ console.log(pc.yellow("Status line already installed. No changes made."));
64
+ return;
65
+ }
66
+ // Falls through to write path below — the install message will read "upgraded".
67
+ }
68
+ if (state === "foreign") {
69
+ const decision = await resolveForeignConflict(opts);
70
+ if (decision === "abort") {
71
+ console.log(pc.yellow("Aborted. Existing statusLine left in place."));
72
+ process.exit(1);
73
+ }
74
+ if (decision === "keep") {
75
+ console.log(pc.yellow("Keeping existing statusLine. No changes made."));
76
+ return;
77
+ }
78
+ // decision === "replace" — fall through
79
+ }
80
+ setStatuslineEntry(settings, command);
81
+ if (opts.dryRun) {
82
+ console.log(pc.cyan("Dry run — would write:"));
83
+ console.log(JSON.stringify(settings, null, 2));
84
+ return;
85
+ }
86
+ const backupPath = backupSettings(settingsPath);
87
+ console.log(pc.dim(`Backup: ${backupPath}`));
88
+ writeSettingsRaw(settingsPath, settings);
89
+ console.log(pc.green(`Installed status line into ${settingsPath}`));
90
+ console.log(pc.dim(`Command: ${command}`));
91
+ }
92
+ catch (err) {
93
+ if (isPromptCancelled(err)) {
94
+ console.log(pc.yellow("\nAborted."));
95
+ process.exit(1);
96
+ }
97
+ console.error(pc.red(`Install failed: ${err instanceof Error ? err.message : String(err)}`));
98
+ process.exit(1);
99
+ }
100
+ });
101
+ }
102
+ async function resolveForeignConflict(opts) {
103
+ if (opts.force)
104
+ return "replace";
105
+ if (!process.stdin.isTTY) {
106
+ console.error(pc.red("An existing statusLine is configured. Re-run with --force to overwrite, " +
107
+ "or remove it first."));
108
+ process.exit(1);
109
+ }
110
+ return select({
111
+ message: "An existing statusLine is configured. What should I do?",
112
+ choices: [
113
+ { name: "Abort — leave it alone (default)", value: "abort" },
114
+ { name: "Keep existing — install nothing", value: "keep" },
115
+ { name: "Replace with byterover's status line", value: "replace" },
116
+ ],
117
+ default: "abort",
118
+ });
119
+ }
120
+ export function isPromptCancelled(err) {
121
+ return (err instanceof Error &&
122
+ (err.name === "ExitPromptError" || err.message.includes("force closed")));
123
+ }
124
+ //# sourceMappingURL=install-statusline.js.map
@@ -72,8 +72,7 @@ export function registerInstallCommand(program) {
72
72
  // Validate executable resolves before touching settings
73
73
  const exe = resolveBridgeExecutable();
74
74
  console.log(pc.dim(`Resolved executable: ${exe}`));
75
- const settingsPath = opts.settingsPath ??
76
- join(getClaudeConfigHome(), "settings.json");
75
+ const settingsPath = opts.settingsPath ?? join(getClaudeConfigHome(), "settings.json");
77
76
  const settings = readSettingsRaw(settingsPath);
78
77
  // Ensure hooks object exists
79
78
  if (!settings.hooks || typeof settings.hooks !== "object") {
@@ -119,7 +118,7 @@ export function registerInstallCommand(program) {
119
118
  console.log(pc.dim(`Backup: ${backupPath}`));
120
119
  writeSettingsRaw(settingsPath, settings);
121
120
  console.log(pc.green(`Installed ${added} hook(s) into ${settingsPath}`));
122
- console.log(pc.dim("Hooks: PostToolUse(Write), PostToolUse(Edit), Stop"));
121
+ console.log(pc.dim("Hooks: PostToolUse(Write), PostToolUse(Edit), Stop, UserPromptSubmit"));
123
122
  }
124
123
  catch (err) {
125
124
  console.error(pc.red(`Install failed: ${err instanceof Error ? err.message : String(err)}`));
@@ -1,4 +1,5 @@
1
1
  import { BrvBridge } from "@byterover/brv-bridge";
2
+ import { buildRecallOutput } from "../build-recall-output.js";
2
3
  import { UserPromptSubmitHookInputSchema } from "../schemas/cc-hook-input.js";
3
4
  import { readStdinJson } from "../stdin.js";
4
5
  export function registerRecallCommand(program) {
@@ -13,22 +14,16 @@ export function registerRecallCommand(program) {
13
14
  if (prompt.trim().length < 5) {
14
15
  process.exit(0);
15
16
  }
16
- // Query ByteRover with the actual user prompt
17
+ // Query ByteRover with the actual user prompt. Newer brv CLIs return
18
+ // matchedDocs/tier/timing alongside content; older CLIs return content only, and
19
+ // buildRecallOutput falls back to parsing the **Sources** block in that case.
17
20
  const bridge = new BrvBridge({ cwd, recallTimeoutMs: 6_000 });
18
- const { content } = await bridge.recall(prompt);
19
- if (!content) {
21
+ const { content, matchedDocs } = await bridge.recall(prompt);
22
+ const output = buildRecallOutput({ content, matchedDocs, cwd });
23
+ if (!output) {
24
+ // No content retrieved — exit silently without injecting anything.
20
25
  process.exit(0);
21
26
  }
22
- // Return additionalContext wrapped in hookSpecificOutput for Claude Code
23
- const output = {
24
- hookSpecificOutput: {
25
- hookEventName: "UserPromptSubmit",
26
- additionalContext: `<byterover-context>\n` +
27
- `The following knowledge is from ByteRover context engine:\n\n` +
28
- `${content}\n` +
29
- `</byterover-context>`,
30
- },
31
- };
32
27
  console.log(JSON.stringify(output));
33
28
  process.exit(0);
34
29
  }
@@ -0,0 +1,11 @@
1
+ import type { Command } from "commander";
2
+ /**
3
+ * Given a parsed Claude Code status payload (or any value), return the line to
4
+ * print on stdout, or `""` when no `.brv/` project is reachable from the
5
+ * resolved cwd. Empty stdout tells Claude Code to hide the status line.
6
+ *
7
+ * `input` is `unknown` so empty/malformed stdin is graceful — when the value
8
+ * is not a usable object, we fall back to `process.cwd()`.
9
+ */
10
+ export declare function produceStatusLine(input: unknown): string;
11
+ export declare function registerStatusCommand(program: Command): void;
@@ -0,0 +1,61 @@
1
+ import { formatStatusLine } from "../format-status-line.js";
2
+ import { detectState, findBrvDir } from "../state-detector.js";
3
+ /**
4
+ * Given a parsed Claude Code status payload (or any value), return the line to
5
+ * print on stdout, or `""` when no `.brv/` project is reachable from the
6
+ * resolved cwd. Empty stdout tells Claude Code to hide the status line.
7
+ *
8
+ * `input` is `unknown` so empty/malformed stdin is graceful — when the value
9
+ * is not a usable object, we fall back to `process.cwd()`.
10
+ */
11
+ export function produceStatusLine(input) {
12
+ const cwd = resolveCwd(input);
13
+ const brvDir = findBrvDir(cwd);
14
+ if (brvDir === undefined)
15
+ return "";
16
+ return formatStatusLine(detectState(brvDir, cwd));
17
+ }
18
+ function resolveCwd(input) {
19
+ if (typeof input === "object" && input !== null) {
20
+ const obj = input;
21
+ const workspace = obj.workspace;
22
+ if (typeof workspace === "object" && workspace !== null) {
23
+ const wsDir = workspace.current_dir;
24
+ if (typeof wsDir === "string" && wsDir.length > 0)
25
+ return wsDir;
26
+ }
27
+ if (typeof obj.cwd === "string" && obj.cwd.length > 0)
28
+ return obj.cwd;
29
+ }
30
+ return process.cwd();
31
+ }
32
+ export function registerStatusCommand(program) {
33
+ program
34
+ .command("status")
35
+ .description("Print the byterover status line for Claude Code (reads CC status payload from stdin)")
36
+ .action(async () => {
37
+ const raw = await readAllStdin();
38
+ let input;
39
+ try {
40
+ input = raw ? JSON.parse(raw) : undefined;
41
+ }
42
+ catch {
43
+ input = undefined;
44
+ }
45
+ const line = produceStatusLine(input);
46
+ if (line.length > 0) {
47
+ process.stdout.write(line + "\n");
48
+ }
49
+ process.exit(0);
50
+ });
51
+ }
52
+ async function readAllStdin() {
53
+ if (process.stdin.isTTY)
54
+ return "";
55
+ const chunks = [];
56
+ for await (const chunk of process.stdin) {
57
+ chunks.push(chunk);
58
+ }
59
+ return Buffer.concat(chunks).toString("utf8").trim();
60
+ }
61
+ //# sourceMappingURL=status.js.map
@@ -0,0 +1,8 @@
1
+ import type { Command } from "commander";
2
+ /**
3
+ * Remove the `statusLine` entry from a settings object only when it carries
4
+ * our bridge marker. Returns true when removed, false otherwise (foreign
5
+ * statusLines are left alone). Mutates `settings`.
6
+ */
7
+ export declare function removeOurStatusline(settings: Record<string, unknown>): boolean;
8
+ export declare function registerUninstallStatuslineCommand(program: Command): void;
@@ -0,0 +1,54 @@
1
+ import { join } from "node:path";
2
+ import pc from "picocolors";
3
+ import { isBridgeHook } from "../bridge-command.js";
4
+ import { getClaudeConfigHome } from "../memory-path.js";
5
+ import { backupSettings, readSettingsRaw, writeSettingsRaw, } from "../schemas/cc-settings.js";
6
+ /**
7
+ * Remove the `statusLine` entry from a settings object only when it carries
8
+ * our bridge marker. Returns true when removed, false otherwise (foreign
9
+ * statusLines are left alone). Mutates `settings`.
10
+ */
11
+ export function removeOurStatusline(settings) {
12
+ const sl = settings.statusLine;
13
+ if (sl === undefined)
14
+ return false;
15
+ if (typeof sl !== "object" || sl === null)
16
+ return false;
17
+ const obj = sl;
18
+ if (typeof obj.command !== "string")
19
+ return false;
20
+ if (!isBridgeHook({ command: obj.command }))
21
+ return false;
22
+ delete settings.statusLine;
23
+ return true;
24
+ }
25
+ export function registerUninstallStatuslineCommand(program) {
26
+ program
27
+ .command("uninstall-statusline")
28
+ .description("Remove the byterover status line from Claude Code settings (leaves foreign statusLines alone)")
29
+ .option("--settings-path <path>", "Override path to Claude Code settings.json")
30
+ .action(async (opts) => {
31
+ try {
32
+ const settingsPath = opts.settingsPath ?? join(getClaudeConfigHome(), "settings.json");
33
+ const settings = readSettingsRaw(settingsPath);
34
+ const removed = removeOurStatusline(settings);
35
+ if (!removed) {
36
+ if (settings.statusLine !== undefined) {
37
+ console.log(pc.yellow("Existing statusLine is not ours. Leaving it untouched."));
38
+ }
39
+ else {
40
+ console.log(pc.yellow("No status line installed. Nothing to remove."));
41
+ }
42
+ return;
43
+ }
44
+ backupSettings(settingsPath);
45
+ writeSettingsRaw(settingsPath, settings);
46
+ console.log(pc.green(`Removed status line from ${settingsPath}`));
47
+ }
48
+ catch (err) {
49
+ console.error(pc.red(`Uninstall failed: ${err instanceof Error ? err.message : String(err)}`));
50
+ process.exit(1);
51
+ }
52
+ });
53
+ }
54
+ //# sourceMappingURL=uninstall-statusline.js.map
@@ -3,6 +3,7 @@ import pc from "picocolors";
3
3
  import { isBridgeHook } from "../bridge-command.js";
4
4
  import { getClaudeConfigHome } from "../memory-path.js";
5
5
  import { backupSettings, readSettingsRaw, writeSettingsRaw, } from "../schemas/cc-settings.js";
6
+ import { removeOurStatusline } from "./uninstall-statusline.js";
6
7
  export function registerUninstallCommand(program) {
7
8
  program
8
9
  .command("uninstall")
@@ -14,54 +15,59 @@ export function registerUninstallCommand(program) {
14
15
  join(getClaudeConfigHome(), "settings.json");
15
16
  const settings = readSettingsRaw(settingsPath);
16
17
  const hooks = settings.hooks;
17
- if (!hooks || typeof hooks !== "object") {
18
- console.log(pc.yellow("No hooks found in settings. Nothing to remove."));
19
- return;
20
- }
21
18
  let removed = 0;
22
- for (const event of Object.keys(hooks)) {
23
- const eventHooks = hooks[event];
24
- if (!Array.isArray(eventHooks))
25
- continue;
26
- // Per-hook removal: within each matcher entry, remove only bridge hooks
27
- for (let i = eventHooks.length - 1; i >= 0; i--) {
28
- const matcherEntry = eventHooks[i];
29
- const innerHooks = matcherEntry.hooks;
30
- if (!Array.isArray(innerHooks))
19
+ if (hooks && typeof hooks === "object") {
20
+ for (const event of Object.keys(hooks)) {
21
+ const eventHooks = hooks[event];
22
+ if (!Array.isArray(eventHooks))
31
23
  continue;
32
- const filtered = innerHooks.filter((h) => {
33
- if (typeof h === "object" &&
34
- h !== null &&
35
- isBridgeHook(h)) {
36
- removed++;
37
- return false;
24
+ // Per-hook removal: within each matcher entry, remove only bridge hooks
25
+ for (let i = eventHooks.length - 1; i >= 0; i--) {
26
+ const matcherEntry = eventHooks[i];
27
+ const innerHooks = matcherEntry.hooks;
28
+ if (!Array.isArray(innerHooks))
29
+ continue;
30
+ const filtered = innerHooks.filter((h) => {
31
+ if (typeof h === "object" &&
32
+ h !== null &&
33
+ isBridgeHook(h)) {
34
+ removed++;
35
+ return false;
36
+ }
37
+ return true;
38
+ });
39
+ if (filtered.length === 0) {
40
+ // All hooks in this matcher entry were bridge hooks — remove the entry
41
+ eventHooks.splice(i, 1);
42
+ }
43
+ else {
44
+ matcherEntry.hooks = filtered;
38
45
  }
39
- return true;
40
- });
41
- if (filtered.length === 0) {
42
- // All hooks in this matcher entry were bridge hooks — remove the entry
43
- eventHooks.splice(i, 1);
44
46
  }
45
- else {
46
- matcherEntry.hooks = filtered;
47
+ // Clean up empty event arrays
48
+ if (eventHooks.length === 0) {
49
+ delete hooks[event];
47
50
  }
48
51
  }
49
- // Clean up empty event arrays
50
- if (eventHooks.length === 0) {
51
- delete hooks[event];
52
+ // Clean up empty hooks object
53
+ if (Object.keys(hooks).length === 0) {
54
+ delete settings.hooks;
52
55
  }
53
56
  }
54
- // Clean up empty hooks object
55
- if (Object.keys(hooks).length === 0) {
56
- delete settings.hooks;
57
- }
58
- if (removed === 0) {
59
- console.log(pc.yellow("No bridge hooks found. Nothing to remove."));
57
+ // Also remove our statusLine entry (foreign statusLines are left alone).
58
+ const statuslineRemoved = removeOurStatusline(settings);
59
+ if (removed === 0 && !statuslineRemoved) {
60
+ console.log(pc.yellow("No bridge hooks or status line found. Nothing to remove."));
60
61
  return;
61
62
  }
62
63
  backupSettings(settingsPath);
63
64
  writeSettingsRaw(settingsPath, settings);
64
- console.log(pc.green(`Removed ${removed} bridge hook(s) from ${settingsPath}`));
65
+ const summary = [];
66
+ if (removed > 0)
67
+ summary.push(`${removed} bridge hook(s)`);
68
+ if (statuslineRemoved)
69
+ summary.push("status line");
70
+ console.log(pc.green(`Removed ${summary.join(" + ")} from ${settingsPath}`));
65
71
  }
66
72
  catch (err) {
67
73
  console.error(pc.red(`Uninstall failed: ${err instanceof Error ? err.message : String(err)}`));
@@ -0,0 +1,2 @@
1
+ import type { BrvState } from "./state-detector.js";
2
+ export declare function formatStatusLine(state: BrvState): string;
@@ -0,0 +1,21 @@
1
+ // ANSI escape sequences scoped to the state suffix only — the
2
+ // `🧠 ByteRover · ` brand prefix stays neutral. Raw escape codes (no
3
+ // picocolors) because picocolors disables colors on non-TTY stdout, but
4
+ // Claude Code reads our stdout from a piped context and renders the ANSI
5
+ // in its own TTY panel.
6
+ const RESET = "\x1b[0m";
7
+ const DIM = "\x1b[2m";
8
+ const YELLOW = "\x1b[33m";
9
+ const CYAN = "\x1b[36m";
10
+ const BRAND_PREFIX = "🧠 ByteRover · ";
11
+ export function formatStatusLine(state) {
12
+ switch (state) {
13
+ case "idle":
14
+ return BRAND_PREFIX + DIM + "idle" + RESET;
15
+ case "curating":
16
+ return BRAND_PREFIX + YELLOW + "📝 curating" + RESET;
17
+ case "dreaming":
18
+ return BRAND_PREFIX + CYAN + "💭 dreaming" + RESET;
19
+ }
20
+ }
21
+ //# sourceMappingURL=format-status-line.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Plugin-only fallback for extracting source paths from a synthesized recall response.
3
+ *
4
+ * Used when the brv CLI does not surface `matchedDocs` in the
5
+ * `brv query --format json` envelope. All five resolution tiers in the CLI emit a
6
+ * `**Sources**:` block in the synthesized content (the format is locked in by the
7
+ * synthesis prompt's `responseFormat` rules and grounding rules), so we can recover paths
8
+ * by parsing the content. When the CLI is current we use `matchedDocs` directly and skip
9
+ * this parser entirely.
10
+ *
11
+ * Returns an empty array on any of:
12
+ * - No `**Sources**:` block at all
13
+ * - `**Sources**: None` (Tier 2 not-found)
14
+ * - Block exists but contains no extractable list items
15
+ */
16
+ export declare function parseSourcesFromContent(content: string): string[];
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Plugin-only fallback for extracting source paths from a synthesized recall response.
3
+ *
4
+ * Used when the brv CLI does not surface `matchedDocs` in the
5
+ * `brv query --format json` envelope. All five resolution tiers in the CLI emit a
6
+ * `**Sources**:` block in the synthesized content (the format is locked in by the
7
+ * synthesis prompt's `responseFormat` rules and grounding rules), so we can recover paths
8
+ * by parsing the content. When the CLI is current we use `matchedDocs` directly and skip
9
+ * this parser entirely.
10
+ *
11
+ * Returns an empty array on any of:
12
+ * - No `**Sources**:` block at all
13
+ * - `**Sources**: None` (Tier 2 not-found)
14
+ * - Block exists but contains no extractable list items
15
+ */
16
+ const PREFIX_TO_STRIP = ".brv/context-tree/";
17
+ // Capture from "**Sources**:" through the next blank-line-then-heading boundary OR end of string.
18
+ // Backticked items are isolated by a non-greedy capture; non-list lines after Sources end the block.
19
+ const SOURCES_BLOCK_REGEX = /\*\*Sources\*\*:\s*\n([\s\S]*?)(?:\n\s*\n|$)/;
20
+ // Match list items with OR without backtick fencing — Tier 2 direct-search emits backticked paths
21
+ // (`- \`path\``) but LLM-synthesised output in Tier 3/4 often drops the backticks (`- path`).
22
+ // Backtick group is optional so both render the same captured path.
23
+ const LIST_ITEM_REGEX = /^\s*-\s*`?([^`\n]+?)`?\s*$/gm;
24
+ export function parseSourcesFromContent(content) {
25
+ if (!content)
26
+ return [];
27
+ const blockMatch = content.match(SOURCES_BLOCK_REGEX);
28
+ if (!blockMatch)
29
+ return [];
30
+ const block = blockMatch[1] ?? "";
31
+ // "Sources: None" inline (no list items) → empty
32
+ if (/^\s*None\s*$/i.test(block))
33
+ return [];
34
+ const paths = [];
35
+ for (const itemMatch of block.matchAll(LIST_ITEM_REGEX)) {
36
+ const raw = itemMatch[1] ?? "";
37
+ paths.push(stripPrefix(raw));
38
+ }
39
+ return paths;
40
+ }
41
+ function stripPrefix(path) {
42
+ return path.startsWith(PREFIX_TO_STRIP)
43
+ ? path.slice(PREFIX_TO_STRIP.length)
44
+ : path;
45
+ }
46
+ //# sourceMappingURL=parse-sources.js.map
@@ -0,0 +1,7 @@
1
+ export declare function getGlobalDataDir(): string;
2
+ export declare function sanitizeProjectPath(resolvedPath: string): string;
3
+ /**
4
+ * `<getGlobalDataDir()>/projects/<sanitizeProjectPath(realpath(cwd))>/`
5
+ * Throws if `cwd` does not exist (matches daemon's `realpathSync` semantics).
6
+ */
7
+ export declare function getProjectDataDir(cwd: string): string;
@@ -0,0 +1,82 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import { homedir, platform } from "node:os";
4
+ import { join } from "node:path";
5
+ /**
6
+ * Mirrors byterover-cli's project data dir resolution. Kept narrowly in sync
7
+ * with byterover-cli's `src/server/utils/path-utils.ts` and
8
+ * `src/server/utils/global-data-path.ts` so we can locate per-project artifacts
9
+ * (curate-log, query-log) the daemon writes outside the project's own `.brv/`.
10
+ *
11
+ * Daemon contract reproduced here:
12
+ * <getGlobalDataDir()>/projects/<sanitizeProjectPath(realpath(cwd))>/
13
+ *
14
+ * If the daemon ever changes either function, this module must be updated in
15
+ * lockstep — there is no runtime contract enforcing parity.
16
+ */
17
+ const GLOBAL_DATA_DIR = "brv";
18
+ const GLOBAL_PROJECTS_DIR = "projects";
19
+ const MAX_SANITIZED_LENGTH = 200;
20
+ const HASH_SUFFIX_LENGTH = 12;
21
+ const WINDOWS_ILLEGAL_CHARS = new Map([
22
+ ['"', "%22"],
23
+ ["*", "%2A"],
24
+ [":", "%3A"],
25
+ ["<", "%3C"],
26
+ [">", "%3E"],
27
+ ["?", "%3F"],
28
+ ["|", "%7C"],
29
+ ]);
30
+ export function getGlobalDataDir() {
31
+ if (process.env.BRV_DATA_DIR !== undefined && process.env.BRV_DATA_DIR !== "") {
32
+ return process.env.BRV_DATA_DIR;
33
+ }
34
+ const p = platform();
35
+ if (p === "win32") {
36
+ const localAppData = process.env.LOCALAPPDATA;
37
+ if (localAppData !== undefined && localAppData !== "") {
38
+ return join(localAppData, GLOBAL_DATA_DIR);
39
+ }
40
+ return join(homedir(), "AppData", "Local", GLOBAL_DATA_DIR);
41
+ }
42
+ if (p === "darwin") {
43
+ return join(homedir(), "Library", "Application Support", GLOBAL_DATA_DIR);
44
+ }
45
+ if (p === "linux") {
46
+ const xdgDataHome = process.env.XDG_DATA_HOME;
47
+ if (xdgDataHome !== undefined && xdgDataHome !== "") {
48
+ return join(xdgDataHome, GLOBAL_DATA_DIR);
49
+ }
50
+ }
51
+ return join(homedir(), ".local", "share", GLOBAL_DATA_DIR);
52
+ }
53
+ export function sanitizeProjectPath(resolvedPath) {
54
+ const normalized = resolvedPath.replace(/^([A-Za-z]):/, "$1");
55
+ const components = normalized.split(/[/\\]+/).filter(Boolean);
56
+ const encoded = components.map((c) => {
57
+ let result = c.replaceAll("%", "%25").replaceAll("--", "%2D%2D");
58
+ for (const [char, replacement] of WINDOWS_ILLEGAL_CHARS) {
59
+ result = result.replaceAll(char, replacement);
60
+ }
61
+ return result;
62
+ });
63
+ const joined = encoded.join("--");
64
+ if (joined.length <= MAX_SANITIZED_LENGTH) {
65
+ return joined;
66
+ }
67
+ const hash = createHash("sha256")
68
+ .update(joined)
69
+ .digest("hex")
70
+ .slice(0, HASH_SUFFIX_LENGTH);
71
+ const prefixLength = MAX_SANITIZED_LENGTH - HASH_SUFFIX_LENGTH - 3;
72
+ return joined.slice(0, prefixLength) + "---" + hash;
73
+ }
74
+ /**
75
+ * `<getGlobalDataDir()>/projects/<sanitizeProjectPath(realpath(cwd))>/`
76
+ * Throws if `cwd` does not exist (matches daemon's `realpathSync` semantics).
77
+ */
78
+ export function getProjectDataDir(cwd) {
79
+ const resolved = realpathSync(cwd);
80
+ return join(getGlobalDataDir(), GLOBAL_PROJECTS_DIR, sanitizeProjectPath(resolved));
81
+ }
82
+ //# sourceMappingURL=project-data-dir.js.map
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Resolve the "age" of a context-tree document for the visible recall summary.
3
+ *
4
+ * Resolution order, falling through on any failure:
5
+ * 1. Frontmatter `updatedAt` — standard topic files (post-curate timestamp)
6
+ * 2. Frontmatter `synthesized_at` — synthesis files produced by `brv dream`
7
+ * 3. Frontmatter `createdAt` — older standard files that lack updatedAt
8
+ * 4. File `mtime` — last resort for files without timestamp frontmatter
9
+ * 5. `undefined` — file missing or path is a cross-project shared source
10
+ *
11
+ * Cross-project paths in the form `[alias]:relative/path.md` are not resolved here —
12
+ * they live in another project's `.brv/context-tree/`, which the plugin does not see.
13
+ */
14
+ export declare function resolveContextTreeAge(projectRoot: string, relativePath: string): Date | undefined;