@taurgis/bonsai 3.2.0 → 3.2.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.
Files changed (162) hide show
  1. package/README.md +7 -4
  2. package/agents/agents/salesforce-docs-researcher.agent.md +14 -1
  3. package/agents/instructions/salesforce-research.instructions.md +2 -1
  4. package/bin/cli.mjs +9 -17
  5. package/dist/base-command.d.ts +22 -12
  6. package/dist/base-command.js +86 -49
  7. package/dist/commands/config/base.js +11 -2
  8. package/dist/commands/config/get.js +8 -12
  9. package/dist/commands/config/index.js +2 -2
  10. package/dist/commands/config/list.js +10 -16
  11. package/dist/commands/config/set.d.ts +2 -1
  12. package/dist/commands/config/set.js +34 -27
  13. package/dist/commands/config/unset.d.ts +2 -1
  14. package/dist/commands/config/unset.js +25 -24
  15. package/dist/commands/fetch.d.ts +7 -15
  16. package/dist/commands/fetch.js +57 -401
  17. package/dist/commands/import.d.ts +4 -9
  18. package/dist/commands/import.js +56 -284
  19. package/dist/commands/inspect.d.ts +7 -1
  20. package/dist/commands/inspect.js +103 -52
  21. package/dist/commands/list.d.ts +13 -1
  22. package/dist/commands/list.js +79 -32
  23. package/dist/commands/prune.d.ts +4 -1
  24. package/dist/commands/prune.js +55 -75
  25. package/dist/commands/status.d.ts +3 -2
  26. package/dist/commands/status.js +52 -79
  27. package/dist/hooks/command-not-found/suggest.d.ts +1 -0
  28. package/dist/hooks/command-not-found/suggest.js +66 -39
  29. package/dist/lib/argv.d.ts +24 -3
  30. package/dist/lib/argv.js +124 -53
  31. package/dist/lib/batch.d.ts +41 -0
  32. package/dist/lib/batch.js +39 -0
  33. package/dist/lib/cache-view.d.ts +21 -0
  34. package/dist/lib/cache-view.js +30 -0
  35. package/dist/lib/cli-emit.d.ts +28 -0
  36. package/dist/lib/cli-emit.js +44 -0
  37. package/dist/lib/cli-error-policy.d.ts +57 -0
  38. package/dist/lib/cli-error-policy.js +74 -0
  39. package/dist/lib/cli-flag-manifest.d.ts +5 -0
  40. package/dist/lib/cli-flag-manifest.js +8 -0
  41. package/dist/lib/cli-presentation.d.ts +39 -0
  42. package/dist/lib/cli-presentation.js +50 -0
  43. package/dist/lib/cli-result-types.d.ts +88 -0
  44. package/dist/lib/cli-result-types.js +2 -0
  45. package/dist/lib/config/index.d.ts +21 -3
  46. package/dist/lib/config/index.js +58 -3
  47. package/dist/lib/config-persist.d.ts +28 -0
  48. package/dist/lib/config-persist.js +29 -0
  49. package/dist/lib/envelope.d.ts +41 -3
  50. package/dist/lib/envelope.js +147 -4
  51. package/dist/lib/help-preflight.d.ts +5 -8
  52. package/dist/lib/help-preflight.js +13 -32
  53. package/dist/lib/json-meta.d.ts +0 -2
  54. package/dist/lib/json-meta.js +21 -25
  55. package/dist/lib/limit-flag.d.ts +11 -1
  56. package/dist/lib/limit-flag.js +19 -6
  57. package/dist/lib/parse-error-ux.d.ts +5 -0
  58. package/dist/lib/parse-error-ux.js +73 -0
  59. package/dist/lib/prune-flags.d.ts +28 -0
  60. package/dist/lib/prune-flags.js +74 -0
  61. package/dist/lib/research/artifact-index.d.ts +11 -2
  62. package/dist/lib/research/artifact-index.js +7 -4
  63. package/dist/lib/research/browser.d.ts +41 -2
  64. package/dist/lib/research/browser.js +139 -50
  65. package/dist/lib/research/capture.d.ts +24 -2
  66. package/dist/lib/research/capture.js +24 -4
  67. package/dist/lib/research/cli-io.d.ts +33 -0
  68. package/dist/lib/research/cli-io.js +27 -0
  69. package/dist/lib/research/compress.d.ts +7 -0
  70. package/dist/lib/research/compress.js +7 -3
  71. package/dist/lib/research/docs/capabilities.d.ts +15 -0
  72. package/dist/lib/research/docs/capabilities.js +5 -0
  73. package/dist/lib/research/docs/clean-dom.d.ts +2 -0
  74. package/dist/lib/research/docs/clean-dom.js +2 -0
  75. package/dist/lib/research/docs/code-blocks.d.ts +2 -0
  76. package/dist/lib/research/docs/code-blocks.js +2 -0
  77. package/dist/lib/research/docs/detect.d.ts +4 -0
  78. package/dist/lib/research/docs/detect.js +4 -0
  79. package/dist/lib/research/docs/machine-readable.d.ts +18 -0
  80. package/dist/lib/research/docs/machine-readable.js +10 -0
  81. package/dist/lib/research/docs/markdown-source.d.ts +20 -0
  82. package/dist/lib/research/docs/markdown-source.js +19 -2
  83. package/dist/lib/research/docs/quality-gates.d.ts +5 -0
  84. package/dist/lib/research/docs/quality-gates.js +4 -0
  85. package/dist/lib/research/docs/rsc.d.ts +9 -2
  86. package/dist/lib/research/docs/rsc.js +5 -2
  87. package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
  88. package/dist/lib/research/docs/section-artifacts.js +12 -0
  89. package/dist/lib/research/docs/sections.d.ts +18 -0
  90. package/dist/lib/research/docs/sections.js +17 -1
  91. package/dist/lib/research/docs/source-map.d.ts +28 -0
  92. package/dist/lib/research/docs/source-map.js +27 -0
  93. package/dist/lib/research/docs/validate.d.ts +35 -2
  94. package/dist/lib/research/docs/validate.js +47 -2
  95. package/dist/lib/research/extract.d.ts +16 -0
  96. package/dist/lib/research/extract.js +12 -1
  97. package/dist/lib/research/fetch-command-service.d.ts +36 -0
  98. package/dist/lib/research/fetch-command-service.js +310 -0
  99. package/dist/lib/research/fetch-result.d.ts +114 -0
  100. package/dist/lib/research/fetch-result.js +212 -0
  101. package/dist/lib/research/fetcher.d.ts +46 -8
  102. package/dist/lib/research/fetcher.js +58 -30
  103. package/dist/lib/research/freshness.d.ts +54 -11
  104. package/dist/lib/research/freshness.js +91 -31
  105. package/dist/lib/research/import-command-service.d.ts +56 -0
  106. package/dist/lib/research/import-command-service.js +381 -0
  107. package/dist/lib/research/keywords.js +5 -1
  108. package/dist/lib/research/markdown.d.ts +12 -1
  109. package/dist/lib/research/markdown.js +13 -5
  110. package/dist/lib/research/persist-artifact.d.ts +23 -0
  111. package/dist/lib/research/persist-artifact.js +27 -0
  112. package/dist/lib/research/prompt-injection.d.ts +8 -0
  113. package/dist/lib/research/prompt-injection.js +42 -13
  114. package/dist/lib/research/proxy.d.ts +14 -8
  115. package/dist/lib/research/proxy.js +14 -10
  116. package/dist/lib/research/revalidate.d.ts +49 -20
  117. package/dist/lib/research/revalidate.js +80 -15
  118. package/dist/lib/research/schema.d.ts +7 -0
  119. package/dist/lib/research/schema.js +2 -0
  120. package/dist/lib/research/secret-scan.d.ts +6 -0
  121. package/dist/lib/research/secret-scan.js +8 -0
  122. package/dist/lib/research/secure-write.d.ts +4 -0
  123. package/dist/lib/research/secure-write.js +31 -2
  124. package/dist/lib/research/storage.d.ts +5 -1
  125. package/dist/lib/research/storage.js +8 -3
  126. package/dist/lib/research/summarize.d.ts +15 -0
  127. package/dist/lib/research/summarize.js +11 -0
  128. package/dist/lib/research/url.d.ts +9 -6
  129. package/dist/lib/research/url.js +33 -21
  130. package/dist/lib/text.d.ts +7 -2
  131. package/dist/lib/text.js +13 -2
  132. package/dist/lib/value-flags.d.ts +14 -0
  133. package/dist/lib/value-flags.js +28 -0
  134. package/dist/sites/coveo.d.ts +10 -0
  135. package/dist/sites/coveo.js +10 -0
  136. package/dist/sites/index.d.ts +16 -0
  137. package/dist/sites/index.js +16 -4
  138. package/dist/sites/markdown-twin.d.ts +31 -0
  139. package/dist/sites/markdown-twin.js +56 -0
  140. package/dist/sites/salesforce/fetch-page.d.ts +8 -2
  141. package/dist/sites/salesforce/fetch-page.js +14 -3
  142. package/dist/sites/salesforce/help-tooling-markdown.d.ts +30 -0
  143. package/dist/sites/salesforce/help-tooling-markdown.js +74 -0
  144. package/dist/sites/salesforce/index.d.ts +1 -0
  145. package/dist/sites/salesforce/index.js +1 -0
  146. package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
  147. package/dist/sites/salesforce-developer/fetch-page.js +6 -1
  148. package/dist/sites/salesforce-developer/index.d.ts +1 -0
  149. package/dist/sites/salesforce-developer/index.js +1 -0
  150. package/dist/sites/salesforce-developer/route-markdown.d.ts +18 -6
  151. package/dist/sites/salesforce-developer/route-markdown.js +29 -67
  152. package/dist/sites/salesforce-doc-fetch.d.ts +39 -5
  153. package/dist/sites/salesforce-doc-fetch.js +52 -7
  154. package/dist/sites/salesforce-dom-probe.d.ts +20 -2
  155. package/dist/sites/salesforce-dom-probe.js +20 -2
  156. package/dist/sites/types.d.ts +14 -8
  157. package/dist/sites/types.js +3 -0
  158. package/oclif.manifest.json +104 -98
  159. package/package.json +19 -7
  160. package/agents/agents/official-docs-researcher.agent.md +0 -87
  161. package/agents/instructions/web-research.instructions.md +0 -44
  162. package/agents/skills/web-research/SKILL.md +0 -118
@@ -1,8 +1,26 @@
1
1
  const HOUR_MS = 3600 * 1000;
2
2
  const DAY_MS = 24 * HOUR_MS;
3
+ /** Calendar multipliers used when parsing duration units (`w`/`m`/`y`). */
4
+ const DAYS_PER_WEEK = 7;
5
+ const DAYS_PER_MONTH = 30;
6
+ const DAYS_PER_YEAR = 365;
7
+ /** Default freshness windows by tier (days). Grace is the additional serve-stale window. */
8
+ const STANDARD_FRESH_DAYS = 30;
9
+ const STANDARD_GRACE_DAYS = 14;
10
+ const STABLE_FRESH_DAYS = 180;
11
+ const STABLE_GRACE_DAYS = 60;
12
+ const VOLATILE_FRESH_DAYS = 7;
13
+ const VOLATILE_GRACE_DAYS = 5;
14
+ /** Shared unit hint for parse failures and empty-duration rejection. */
15
+ const DURATION_FORMAT_HINT = `Use a whole number plus a unit: ` +
16
+ `h (hours), d (days), w (weeks), m (months), or y (years), e.g. '2h', '7d', '6m'.`;
3
17
  /**
4
- * Parses simple TTL duration strings to milliseconds. The unit `m` is months (not minutes); the
5
- * smallest unit is hours, since sub-hour cache lifespans are not meaningful for research artifacts.
18
+ * Parses a TTL/duration string into milliseconds.
19
+ * The unit `m` means months (not minutes); the smallest unit is hours.
20
+ *
21
+ * @param ttl - Whole number plus unit (`h`/`d`/`w`/`m`/`y`).
22
+ * @returns Duration in milliseconds.
23
+ * @throws {Error} When the format is invalid or the duration is zero.
6
24
  */
7
25
  export function parseTtlToMs(ttl) {
8
26
  const match = ttl.match(/^(\d+)([hdwmy])$/);
@@ -10,35 +28,44 @@ export function parseTtlToMs(ttl) {
10
28
  // Deliberately does not start with "Invalid": durationFlagError wraps this as
11
29
  // "Invalid <flag>: <message>", and a leading "Invalid" here produced a "Invalid --ttl:
12
30
  // Invalid TTL format" stutter.
13
- throw new Error(`Duration "${ttl}" is not a valid format. Use a whole number plus a unit: ` +
14
- `h (hours), d (days), w (weeks), m (months), or y (years), e.g. '24h', '7d', '6m'.`);
31
+ throw new Error(`Duration "${ttl}" is not a valid format. ${DURATION_FORMAT_HINT}`);
15
32
  }
16
- const val = parseInt(match[1] || '', 10);
33
+ const amount = parseInt(match[1] || '', 10);
17
34
  const unit = match[2] || '';
35
+ // Zero-length durations would match every entry (age >= 0) and turn prune filters into a wipe.
36
+ if (amount === 0) {
37
+ throw new Error(`Duration "${ttl}" must be greater than zero.`);
38
+ }
18
39
  switch (unit) {
19
40
  case 'h':
20
- return val * HOUR_MS;
41
+ return amount * HOUR_MS;
21
42
  case 'd':
22
- return val * DAY_MS;
43
+ return amount * DAY_MS;
23
44
  case 'w':
24
- return val * 7 * DAY_MS;
45
+ return amount * DAYS_PER_WEEK * DAY_MS;
25
46
  case 'm':
26
- return val * 30 * DAY_MS;
47
+ return amount * DAYS_PER_MONTH * DAY_MS;
27
48
  case 'y':
28
- return val * 365 * DAY_MS;
49
+ return amount * DAYS_PER_YEAR * DAY_MS;
29
50
  default:
30
51
  return 0;
31
52
  }
32
53
  }
33
54
  /**
34
- * Validate a duration-valued flag. Returns an actionable error message that names the
35
- * offending flag when the value is malformed, or null when the flag is absent or valid.
36
- * Callers map a non-null message onto `this.error(msg, { exit: 2 })`, so every command
37
- * reports the same parse failure while naming the exact flag the user passed.
55
+ * Validate a duration-valued flag.
56
+ *
57
+ * @param flag - Flag token for the error message (e.g. `--ttl`).
58
+ * @param value - Raw flag value, or `undefined` when omitted.
59
+ * @returns Actionable error message, or `null` when absent/valid.
38
60
  */
39
61
  export function durationFlagError(flag, value) {
40
- if (!value)
62
+ // Absent is fine; empty/whitespace is almost always a shell-quoting mistake and must not
63
+ // silently mean "no override" (unlike a truly omitted flag).
64
+ if (value === undefined)
41
65
  return null;
66
+ if (value.trim() === '') {
67
+ return `Invalid ${flag}: Duration must not be empty. ${DURATION_FORMAT_HINT}`;
68
+ }
42
69
  try {
43
70
  parseTtlToMs(value);
44
71
  return null;
@@ -48,36 +75,48 @@ export function durationFlagError(flag, value) {
48
75
  }
49
76
  }
50
77
  /**
51
- * Derives the active freshness policy based on the tier and optional TTL override.
78
+ * Derives the active freshness policy from a tier and optional TTL override.
79
+ * When a TTL override is set, grace scales proportionally to the tier's default ratio.
80
+ *
81
+ * @param tier - Freshness tier (`stable` / `standard` / `volatile`).
82
+ * @param ttlOverride - Optional TTL string replacing the tier's fresh window.
83
+ * @returns Fresh and grace windows in milliseconds.
52
84
  */
53
- export function getPolicy(tier, ttlOverride) {
54
- let freshWindowMs = 30 * DAY_MS;
55
- let graceWindowMs = 14 * DAY_MS;
85
+ export function resolveFreshnessPolicy(tier, ttlOverride) {
86
+ let freshWindowMs = STANDARD_FRESH_DAYS * DAY_MS;
87
+ let graceWindowMs = STANDARD_GRACE_DAYS * DAY_MS;
56
88
  if (tier === 'stable') {
57
- freshWindowMs = 180 * DAY_MS;
58
- graceWindowMs = 60 * DAY_MS;
89
+ freshWindowMs = STABLE_FRESH_DAYS * DAY_MS;
90
+ graceWindowMs = STABLE_GRACE_DAYS * DAY_MS;
59
91
  }
60
92
  else if (tier === 'volatile') {
61
- freshWindowMs = 7 * DAY_MS;
62
- graceWindowMs = 5 * DAY_MS;
93
+ freshWindowMs = VOLATILE_FRESH_DAYS * DAY_MS;
94
+ graceWindowMs = VOLATILE_GRACE_DAYS * DAY_MS;
63
95
  }
64
- const activeTtl = ttlOverride;
65
- if (activeTtl) {
66
- freshWindowMs = parseTtlToMs(activeTtl);
67
- const defaultFresh = tier === 'stable' ? 180 * DAY_MS : tier === 'volatile' ? 7 * DAY_MS : 30 * DAY_MS;
68
- const defaultGrace = tier === 'stable' ? 60 * DAY_MS : tier === 'volatile' ? 5 * DAY_MS : 14 * DAY_MS;
96
+ if (ttlOverride) {
97
+ const defaultFresh = freshWindowMs;
98
+ const defaultGrace = graceWindowMs;
99
+ freshWindowMs = parseTtlToMs(ttlOverride);
69
100
  graceWindowMs = Math.floor(freshWindowMs * (defaultGrace / defaultFresh));
70
101
  }
71
102
  return { freshWindowMs, graceWindowMs };
72
103
  }
73
104
  /**
74
105
  * Evaluates the freshness status of a cached artifact.
106
+ * `ttlOverride` / `tierOverride` let status (and similar read-only checks) evaluate "what if"
107
+ * policies without mutating the stored metadata. When omitted, the artifact's own tier/TTL win.
108
+ *
109
+ * @param meta - Artifact metadata providing timestamps, tier, and stored TTL.
110
+ * @param currentTime - Clock used for age calculation.
111
+ * @param ttlOverride - Optional TTL replacing the stored TTL for this evaluation.
112
+ * @param tierOverride - Optional tier replacing the stored tier for this evaluation.
113
+ * @returns `fresh`, `stale_grace`, or `stale_expired`.
75
114
  */
76
- export function evaluateFreshness(meta, currentTime, ttlOverride) {
115
+ export function evaluateFreshness(meta, currentTime, ttlOverride, tierOverride) {
77
116
  const fetched = meta.fetched_at ? new Date(meta.fetched_at).getTime() : 0;
78
117
  const validated = meta.validated_at ? new Date(meta.validated_at).getTime() : 0;
79
118
  const baseTime = Math.max(fetched, validated);
80
- const { freshWindowMs, graceWindowMs } = getPolicy(meta.tier, ttlOverride || meta.ttl);
119
+ const { freshWindowMs, graceWindowMs } = resolveFreshnessPolicy(tierOverride ?? meta.tier, ttlOverride || meta.ttl);
81
120
  const ageMs = currentTime.getTime() - baseTime;
82
121
  if (ageMs <= freshWindowMs) {
83
122
  return 'fresh';
@@ -88,7 +127,12 @@ export function evaluateFreshness(meta, currentTime, ttlOverride) {
88
127
  return 'stale_expired';
89
128
  }
90
129
  /**
91
- * Checks if the cached entry's age exceeds the specified max-age duration.
130
+ * Checks whether the cached entry's age exceeds the specified max-age duration.
131
+ *
132
+ * @param cached - Artifact (or metadata bag) with fetch/validate timestamps.
133
+ * @param currentTime - Clock used for age calculation.
134
+ * @param maxAge - Duration string, or `undefined` to skip the check.
135
+ * @returns `true` when max-age is set and exceeded.
92
136
  */
93
137
  export function checkMaxAgeExpired(cached, currentTime, maxAge) {
94
138
  if (!maxAge)
@@ -101,3 +145,19 @@ export function checkMaxAgeExpired(cached, currentTime, maxAge) {
101
145
  const ageMs = currentTime.getTime() - baseTime;
102
146
  return ageMs > parseTtlToMs(maxAge);
103
147
  }
148
+ /**
149
+ * Freshness for a cached artifact, applying `--max-age` before the tier/TTL windows.
150
+ *
151
+ * @param cached - Artifact with full metadata.
152
+ * @param currentTime - Clock used for age calculation.
153
+ * @param options.ttl - Optional TTL override.
154
+ * @param options.maxAge - Optional max-age duration; when exceeded returns `stale_expired`.
155
+ * @param options.tier - Optional tier override.
156
+ * @returns `fresh`, `stale_grace`, or `stale_expired`.
157
+ */
158
+ export function evaluateFreshnessWithMaxAge(cached, currentTime, options = {}) {
159
+ if (checkMaxAgeExpired(cached, currentTime, options.maxAge)) {
160
+ return 'stale_expired';
161
+ }
162
+ return evaluateFreshness(cached.metadata, currentTime, options.ttl, options.tier);
163
+ }
@@ -0,0 +1,56 @@
1
+ import type { StorageMode } from '../config/index.js';
2
+ import type { ResearchArtifact } from './schema.js';
3
+ import { type StoreRoots } from './store-roots.js';
4
+ import { type CliIo } from './cli-io.js';
5
+ /** Minimal file-stat shape used by import input hooks. */
6
+ export interface ImportCommandFileStat {
7
+ isFile(): boolean;
8
+ size: number;
9
+ }
10
+ /** Injected IO hooks so import stays testable without a live TTY. */
11
+ export interface ImportCommandInputHooks {
12
+ stdinIsInteractive(): boolean;
13
+ readStdin(limitBytes: number): Promise<string>;
14
+ fsExistsSync(filePath: string): boolean;
15
+ fsStatSync(filePath: string): ImportCommandFileStat;
16
+ fsReadFileSync(filePath: string): string;
17
+ }
18
+ /** Parsed flags for one import command invocation. */
19
+ export interface ImportCommandFlags {
20
+ stdin: boolean;
21
+ file?: string;
22
+ inputFormat: 'compressed' | 'detailed';
23
+ topic?: string;
24
+ tags?: string[];
25
+ tier: 'stable' | 'standard' | 'volatile';
26
+ sourceUrls: string[];
27
+ ttl?: string;
28
+ storage?: StorageMode;
29
+ }
30
+ /** Positional args for one import command invocation. */
31
+ export interface ImportCommandArgs {
32
+ url?: string;
33
+ }
34
+ /** Validated import inputs ready for persistence. */
35
+ export interface PreparedImportCommand {
36
+ args: ImportCommandArgs;
37
+ artifact: ResearchArtifact;
38
+ cacheKey: string;
39
+ flags: ImportCommandFlags;
40
+ hasSingle: boolean;
41
+ roots: StoreRoots;
42
+ singleNormalizedUrl: string;
43
+ sourceUrls: string[];
44
+ }
45
+ /**
46
+ * Validate the request, read and validate the input content, and build the artifact — everything
47
+ * up to (but excluding) the cache write, so the command can resolve dry-run mode in between.
48
+ */
49
+ export declare function prepareImportCommandService(opts: {
50
+ args: ImportCommandArgs;
51
+ flags: ImportCommandFlags;
52
+ io: CliIo;
53
+ input: ImportCommandInputHooks;
54
+ }): Promise<PreparedImportCommand>;
55
+ /** Persist (or dry-run preview) a prepared import and return the command's `--json` data payload. */
56
+ export declare function finishImportCommandService(prepared: PreparedImportCommand, dryRun: boolean, io: CliIo): unknown;
@@ -0,0 +1,381 @@
1
+ import { Errors } from '@oclif/core';
2
+ import { createHash } from 'node:crypto';
3
+ import { resolve } from 'node:path';
4
+ import { loadSummaryLevel } from '../config/index.js';
5
+ import { buildCompressed } from './compress.js';
6
+ import { durationFlagError, resolveFreshnessPolicy } from './freshness.js';
7
+ import { applyAutoTags } from './keywords.js';
8
+ import { persistArtifact } from './persist-artifact.js';
9
+ import { sanitizePromptInjection } from './prompt-injection.js';
10
+ import { getArtifactPath } from './storage.js';
11
+ import { loadStoreRoots } from './store-roots.js';
12
+ import { estimateTokens } from './token-estimate.js';
13
+ import { deriveCacheKey } from './cache-key.js';
14
+ import { normalizeUrl } from './url.js';
15
+ import { failInvalidUrl } from './cli-io.js';
16
+ import { formatHumanFields } from '../cli-presentation.js';
17
+ const INPUT_LIMIT_BYTES = 1024 * 1024;
18
+ // ponytail: 1s stdin idle timeout is enough for piped agent input; raise if interactive paste
19
+ // workflows need longer before the read aborts.
20
+ const STDIN_TIMEOUT_MS = 1000;
21
+ /**
22
+ * Validate the request, read and validate the input content, and build the artifact — everything
23
+ * up to (but excluding) the cache write, so the command can resolve dry-run mode in between.
24
+ */
25
+ export async function prepareImportCommandService(opts) {
26
+ const { args, flags, input, io } = opts;
27
+ const hasSingle = Boolean(args.url);
28
+ const hasMulti = flags.sourceUrls.length > 0;
29
+ const ttlErr = durationFlagError('--ttl', flags.ttl);
30
+ if (ttlErr)
31
+ io.error(ttlErr, { exit: 2, code: 'INVALID_DURATION' });
32
+ validateSourceMode(hasSingle, hasMulti, flags, io);
33
+ const rawInput = await readAndValidateInput(flags, input, io);
34
+ const sourceUrls = getSourceUrls(hasMulti, flags.sourceUrls, args.url, io);
35
+ const singleNormalizedUrl = hasSingle ? sourceUrls[0] || '' : '';
36
+ const cacheKey = deriveImportCacheKey(hasSingle, singleNormalizedUrl, sourceUrls, flags);
37
+ const artifact = buildImportArtifact({
38
+ hasSingle,
39
+ singleUrl: args.url,
40
+ singleNormalizedUrl,
41
+ sourceUrls,
42
+ cacheKey,
43
+ rawInput,
44
+ flags,
45
+ io,
46
+ });
47
+ const roots = loadStoreRoots({
48
+ configDir: io.configDir,
49
+ cwd: io.cwd,
50
+ dataDir: io.dataDir,
51
+ flagOverride: flags.storage,
52
+ });
53
+ return {
54
+ args,
55
+ artifact,
56
+ cacheKey,
57
+ flags,
58
+ hasSingle,
59
+ roots,
60
+ singleNormalizedUrl,
61
+ sourceUrls,
62
+ };
63
+ }
64
+ /** Persist (or dry-run preview) a prepared import and return the command's `--json` data payload. */
65
+ export function finishImportCommandService(prepared, dryRun, io) {
66
+ const { args, artifact, cacheKey, flags, hasSingle, roots, singleNormalizedUrl, sourceUrls } = prepared;
67
+ const writeResult = persistArtifact({
68
+ roots,
69
+ cacheKey,
70
+ artifact,
71
+ dryRun,
72
+ kind: 'import',
73
+ });
74
+ const storagePath = getArtifactPath(writeResult.dataDir, cacheKey);
75
+ if (writeResult.redirectWarning) {
76
+ io.warn(writeResult.redirectWarning);
77
+ }
78
+ if (!io.json) {
79
+ logImportHumanSuccess(io, {
80
+ dryRun,
81
+ cacheKey,
82
+ storagePath,
83
+ hasSingle,
84
+ flags,
85
+ sourceUrls,
86
+ });
87
+ }
88
+ return {
89
+ schemaVersion: 1,
90
+ command: 'import',
91
+ dryRun,
92
+ cache: {
93
+ key: cacheKey,
94
+ status: dryRun ? 'would_import' : 'imported',
95
+ freshness: 'fresh',
96
+ path: storagePath,
97
+ storage: roots.mode,
98
+ redirectedToGlobal: writeResult.redirected,
99
+ },
100
+ artifactType: artifact.metadata.artifact_type,
101
+ topic: artifact.metadata.topic,
102
+ sourceUrls,
103
+ source: {
104
+ // Multi-source notes have no single primary URL -- expose null plus sourceUrls above.
105
+ url: hasSingle ? args.url : null,
106
+ normalizedUrl: hasSingle ? singleNormalizedUrl : null,
107
+ captureMethod: 'agent_supplied',
108
+ extractionStatus: 'agent_supplied',
109
+ extractionConfidence: 'high',
110
+ qualityNotes: artifact.metadata.quality_notes,
111
+ fetchedAt: null,
112
+ validatedAt: artifact.metadata.validated_at,
113
+ staleAfter: artifact.metadata.stale_after,
114
+ },
115
+ format: flags.inputFormat,
116
+ tokenEstimate: flags.inputFormat === 'compressed'
117
+ ? artifact.metadata.token_estimate.compressed
118
+ : artifact.metadata.token_estimate.detailed,
119
+ content: flags.inputFormat === 'compressed' ? artifact.compressed : artifact.detailed,
120
+ };
121
+ }
122
+ function logImportHumanSuccess(io, opts) {
123
+ const { dryRun, cacheKey, storagePath, hasSingle, flags, sourceUrls } = opts;
124
+ io.log(dryRun
125
+ ? `[dry-run] Would import research artifact.`
126
+ : `Successfully imported research artifact.`);
127
+ const fields = [
128
+ ['Cache Key', cacheKey],
129
+ ['Storage Path', storagePath],
130
+ ];
131
+ let topic;
132
+ if (!hasSingle) {
133
+ topic = resolvedTopic(flags);
134
+ fields.push(['Topic', topic], ['Source URLs', sourceUrls.join(', ')]);
135
+ }
136
+ for (const line of formatHumanFields(fields))
137
+ io.log(line);
138
+ if (topic) {
139
+ io.log(`\nTip: find it again with ${io.bin} list --topic "${topic}"`);
140
+ }
141
+ }
142
+ function importSourceSuggestions(bin) {
143
+ return [
144
+ `Single-source import: ${bin} import https://example.com/docs --file notes.md`,
145
+ `Multi-source import: ${bin} import --source-url https://example.com/a --topic "Topic" --file notes.md`,
146
+ ];
147
+ }
148
+ function stdinImportSuggestions(bin) {
149
+ return [
150
+ `Pipe Markdown content, e.g. cat notes.md | ${bin} import <url> --stdin`,
151
+ `Or use the standard stdin placeholder: cat notes.md | ${bin} import <url> --file -`,
152
+ `Or read from a file: ${bin} import <url> --file notes.md`,
153
+ ];
154
+ }
155
+ function validateSourceMode(hasSingle, hasMulti, flags, io) {
156
+ if (hasSingle && hasMulti) {
157
+ io.error('Cannot specify both positional <url> and --source-url flags.', {
158
+ exit: 2,
159
+ code: 'CONFLICTING_FLAGS',
160
+ suggestions: [
161
+ 'Use the positional URL for single-source import, or use only --source-url for multi-source import.',
162
+ ],
163
+ });
164
+ }
165
+ if (!hasSingle && !hasMulti) {
166
+ io.error('Must specify either positional <url> (for single-source) or --source-url (for multi-source) import.', { exit: 2, code: 'MISSING_URL', suggestions: importSourceSuggestions(io.bin) });
167
+ }
168
+ if (hasMulti && !resolvedTopic(flags)) {
169
+ io.error('Multi-source import requires the --topic flag.', {
170
+ exit: 2,
171
+ code: 'MISSING_TOPIC',
172
+ suggestions: [`Add a topic: ${io.bin} import --source-url <url> --topic "Topic"`],
173
+ });
174
+ }
175
+ if (!flags.stdin && !flags.file) {
176
+ io.error('Either --stdin or --file <path> must be specified to import content.', {
177
+ exit: 2,
178
+ code: 'MISSING_INPUT',
179
+ suggestions: stdinImportSuggestions(io.bin),
180
+ });
181
+ }
182
+ if (flags.stdin && flags.file) {
183
+ io.error('Cannot specify both --stdin and --file. Choose one input source.', {
184
+ exit: 2,
185
+ code: 'CONFLICTING_FLAGS',
186
+ suggestions: [
187
+ 'Pipe content with --stdin, or read a file with --file notes.md; do not pass both.',
188
+ ],
189
+ });
190
+ }
191
+ }
192
+ async function readStdinWithGuard(input, io) {
193
+ if (input.stdinIsInteractive()) {
194
+ io.error('No data piped to --stdin.', {
195
+ exit: 2,
196
+ code: 'MISSING_STDIN',
197
+ suggestions: stdinImportSuggestions(io.bin),
198
+ });
199
+ }
200
+ let rawInput = '';
201
+ try {
202
+ rawInput = await Promise.race([
203
+ input.readStdin(INPUT_LIMIT_BYTES),
204
+ new Promise((_, reject) => setTimeout(() => reject(new Error('STDIN_TIMEOUT')), STDIN_TIMEOUT_MS)),
205
+ ]);
206
+ }
207
+ catch (err) {
208
+ if (err.message === 'STDIN_TIMEOUT') {
209
+ io.error('No stdin data received. Pipe content or use --file.', {
210
+ exit: 2,
211
+ code: 'MISSING_STDIN',
212
+ suggestions: stdinImportSuggestions(io.bin),
213
+ });
214
+ }
215
+ if (err.message.includes('stdin size limit exceeded')) {
216
+ io.error(err.message, {
217
+ exit: 1,
218
+ code: 'STDIN_TOO_LARGE',
219
+ suggestions: ['Reduce the input below 1 MiB, or split the note into smaller imports.'],
220
+ });
221
+ }
222
+ // Genuine stream failure reading stdin -> I/O failure (exit 1).
223
+ io.error(err.message, {
224
+ exit: 1,
225
+ code: 'IO_ERROR',
226
+ suggestions: ['Retry with --file notes.md if stdin is unreliable in this shell.'],
227
+ });
228
+ }
229
+ return rawInput;
230
+ }
231
+ function readAndValidateFile(filePath, input, io) {
232
+ if (!input.fsExistsSync(filePath)) {
233
+ io.error(`File does not exist: ${filePath}`, {
234
+ exit: 2,
235
+ code: 'FILE_NOT_FOUND',
236
+ suggestions: ['Check the path, or pipe content with --stdin.'],
237
+ });
238
+ }
239
+ const stat = input.fsStatSync(filePath);
240
+ if (!stat.isFile()) {
241
+ io.error(`Path is not a file: ${filePath}`, {
242
+ exit: 2,
243
+ code: 'NOT_A_FILE',
244
+ suggestions: ['Pass a Markdown file path, or pipe content with --stdin.'],
245
+ });
246
+ }
247
+ if (stat.size > INPUT_LIMIT_BYTES) {
248
+ io.error('File size limit exceeded (max 1 MiB).', {
249
+ exit: 1,
250
+ code: 'FILE_TOO_LARGE',
251
+ suggestions: ['Reduce the file below 1 MiB, or split the note into smaller imports.'],
252
+ });
253
+ }
254
+ const content = input.fsReadFileSync(filePath);
255
+ if (!content.trim()) {
256
+ io.error('Empty file content provided.', {
257
+ exit: 2,
258
+ code: 'EMPTY_INPUT',
259
+ suggestions: ['Provide non-empty Markdown content.'],
260
+ });
261
+ }
262
+ return content;
263
+ }
264
+ async function readAndValidateInput(flags, input, io) {
265
+ const fileReadsStdin = flags.file === '-';
266
+ if (flags.stdin || fileReadsStdin) {
267
+ const rawInput = await readStdinWithGuard(input, io);
268
+ if (!rawInput.trim()) {
269
+ io.error('Empty stdin content provided.', {
270
+ exit: 2,
271
+ code: 'EMPTY_INPUT',
272
+ suggestions: stdinImportSuggestions(io.bin),
273
+ });
274
+ }
275
+ return rawInput;
276
+ }
277
+ // validateSourceMode already required --stdin or --file.
278
+ const filePath = resolve(flags.file);
279
+ try {
280
+ return readAndValidateFile(filePath, input, io);
281
+ }
282
+ catch (err) {
283
+ // readAndValidateFile raises usage errors via io.error, which throw CLIErrors carrying their
284
+ // own exit code and message -- preserve those. Only an unexpected read failure is wrapped.
285
+ if (err instanceof Errors.CLIError)
286
+ throw err;
287
+ io.error(`Failed to read file: ${err.message}`, {
288
+ exit: 1,
289
+ code: 'IO_ERROR',
290
+ suggestions: ['Check file permissions, or pipe content with --stdin.'],
291
+ });
292
+ }
293
+ }
294
+ /** Trimmed --topic, or null when absent/whitespace-only. */
295
+ function resolvedTopic(flags) {
296
+ const topic = flags.topic?.trim();
297
+ return topic || null;
298
+ }
299
+ function getSourceUrls(hasMulti, multiUrls, singleUrl, io) {
300
+ const urls = hasMulti ? multiUrls : singleUrl ? [singleUrl] : [];
301
+ const normalized = urls.map((u) => {
302
+ try {
303
+ return normalizeUrl(u);
304
+ }
305
+ catch (err) {
306
+ // Shared exit point: a forgotten scheme reports MISSING_URL_SCHEME like every other command,
307
+ // while genuinely malformed input stays INVALID_URL.
308
+ failInvalidUrl(io.error, u, err.message);
309
+ }
310
+ });
311
+ // Deduplicate after normalization so repeated --source-url values do not inflate the key.
312
+ return hasMulti ? [...new Set(normalized)].sort() : normalized;
313
+ }
314
+ function deriveImportCacheKey(hasSingle, singleNormalizedUrl, sourceUrls, flags) {
315
+ if (hasSingle) {
316
+ return deriveCacheKey(singleNormalizedUrl);
317
+ }
318
+ const combinedString = [resolvedTopic(flags).toLowerCase(), ...sourceUrls].join('|');
319
+ return createHash('sha256').update(combinedString).digest('hex');
320
+ }
321
+ function buildImportArtifact(opts) {
322
+ const { hasSingle, singleUrl, singleNormalizedUrl, sourceUrls, cacheKey, rawInput, flags, io } = opts;
323
+ const currentTime = new Date();
324
+ const ttl = flags.ttl || null;
325
+ const { freshWindowMs } = resolveFreshnessPolicy(flags.tier, ttl);
326
+ const staleAfterTime = new Date(currentTime.getTime() + freshWindowMs);
327
+ const detailed = sanitizePromptInjection(rawInput);
328
+ // Always sanitize -- agent-supplied notes are untrusted regardless of density. When the caller
329
+ // already provided compressed Markdown, keep that body; otherwise condense the detailed import.
330
+ const compressed = flags.inputFormat === 'detailed'
331
+ ? buildCompressed(detailed, loadSummaryLevel(io.configDir, io.cwd))
332
+ : detailed;
333
+ const contentHash = createHash('sha256').update(detailed).digest('hex');
334
+ const metadata = {
335
+ schema_version: 1,
336
+ artifact_type: hasSingle ? 'source' : 'research_note',
337
+ source_url: hasSingle ? singleUrl : '',
338
+ source_urls: sourceUrls,
339
+ normalized_url: singleNormalizedUrl,
340
+ cache_key: cacheKey,
341
+ topic: resolvedTopic(flags),
342
+ tags: flags.tags || [],
343
+ format_available: ['compressed', 'detailed'],
344
+ tier: flags.tier,
345
+ ttl,
346
+ fetched_at: null,
347
+ validated_at: currentTime.toISOString(),
348
+ stale_after: staleAfterTime.toISOString(),
349
+ capture_method: 'agent_supplied',
350
+ extraction_status: 'agent_supplied',
351
+ extraction_confidence: 'high',
352
+ quality_notes: ['agent-supplied research import'],
353
+ supplied_at: currentTime.toISOString(),
354
+ supplied_by: null,
355
+ etag: null,
356
+ last_modified: null,
357
+ content_hash: contentHash,
358
+ token_estimate: {
359
+ compressed: estimateTokens(compressed),
360
+ detailed: estimateTokens(detailed),
361
+ },
362
+ status: 'active',
363
+ site_module_id: null,
364
+ docs_engine: null,
365
+ docs_framework: null,
366
+ source_doc_url: null,
367
+ search_provider: null,
368
+ parent_cache_key: null,
369
+ section_anchor: null,
370
+ section_heading_path: null,
371
+ };
372
+ // Auto-tag from the content when the caller supplied none, so imported notes stay searchable by
373
+ // keyword without forcing manual tagging. Explicit --tags always win (handled in applyAutoTags).
374
+ return applyAutoTags({
375
+ metadata,
376
+ summary: `Synthesized research for ${hasSingle ? singleNormalizedUrl : resolvedTopic(flags)}`,
377
+ compressed,
378
+ detailed,
379
+ provenance: `Imported via agent-supplied research at ${currentTime.toISOString()}`,
380
+ });
381
+ }
@@ -2,6 +2,7 @@
2
2
  // supplies no tags. This is a deliberately simple term-frequency heuristic.
3
3
  // ponytail: TF + stopwords + heading boost. Ceiling: no phrase/entity detection, English-only
4
4
  // stopwords. Upgrade path: swap in RAKE/YAKE or an LLM pass behind the same extractKeywords() API.
5
+ import { stripSecrets } from './secret-scan.js';
5
6
  const AUTO_TAG_NOTE = 'auto-generated tags via keyword extraction';
6
7
  // Common English function words plus a few markdown/doc-noise words. Kept inline (no dependency)
7
8
  // because the list is small and stable.
@@ -151,7 +152,10 @@ export function tokenize(text) {
151
152
  return raw.map((w) => w.replace(/^[.\-]+|[.\-]+$/g, '')).filter(Boolean);
152
153
  }
153
154
  function cleanNoise(text) {
154
- return text
155
+ // Strip secrets before code fences/URLs so a credential embedded in either still gets caught —
156
+ // and before tokenize() lowercases/splits the text, which would hide case-sensitive or
157
+ // underscore-anchored secret patterns (AKIA…, ghp_…, AIza…) from detection.
158
+ return stripSecrets(text)
155
159
  .replace(/```[\s\S]*?```/g, ' ')
156
160
  .replace(/`[^`]*`/g, ' ')
157
161
  .replace(/https?:\/\/\S+/g, ' ');
@@ -1,6 +1,17 @@
1
+ /**
2
+ * Drops empty anchor links (e.g. `[](url)`) from Markdown. These arise from icon-only heading
3
+ * permalinks and ReadMe query-param chips that carry no text content, wasting tokens.
4
+ * Images (`![](url)`) are intentionally not matched.
5
+ *
6
+ * @param markdown - Markdown text to clean.
7
+ * @returns Markdown with text-less links removed.
8
+ */
9
+ export declare function dropEmptyLinks(markdown: string): string;
1
10
  /**
2
11
  * Converts HTML content to clean, deterministic Markdown.
3
12
  * Configured to use ATX-style headings (#) and fenced code blocks (```), with GFM tables.
13
+ *
14
+ * @param html - HTML string to convert.
15
+ * @returns Clean Markdown with empty links stripped.
4
16
  */
5
- export declare function dropEmptyLinks(markdown: string): string;
6
17
  export declare function htmlToMarkdown(html: string): string;