@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,38 +1,28 @@
1
- import { Args, Errors, Flags } from '@oclif/core';
2
- import { createHash } from 'node:crypto';
1
+ import { Args, Flags } from '@oclif/core';
3
2
  import * as fs from 'node:fs';
4
- import * as path from 'node:path';
5
3
  import { BaseCommand } from '../base-command.js';
6
- import { normalizeUrl } from '../lib/research/url.js';
7
- import { deriveCacheKey } from '../lib/research/cache-key.js';
8
- import { getArtifactPath } from '../lib/research/storage.js';
9
- import { loadStoreRoots } from '../lib/research/store-roots.js';
10
- import { writeArtifactSecurely } from '../lib/research/secure-write.js';
11
- import { loadSummaryLevel } from '../lib/config/index.js';
12
- import { durationFlagError, getPolicy } from '../lib/research/freshness.js';
13
- import { buildCompressed } from '../lib/research/compress.js';
14
- import { sanitizePromptInjection } from '../lib/research/prompt-injection.js';
15
- import { applyAutoTags } from '../lib/research/keywords.js';
16
- import { estimateTokens } from '../lib/research/token-estimate.js';
4
+ import { finishImportCommandService, prepareImportCommandService, } from '../lib/research/import-command-service.js';
5
+ import { CLI_FLAG_DESCRIPTIONS } from '../lib/cli-presentation.js';
6
+ /** Import research notes from a file or stdin. */
17
7
  export default class ResearchImport extends BaseCommand {
18
8
  static id = 'import';
19
- static summary = 'Import agent-supplied clean Markdown notes into the local cache.';
20
- static description = 'Accepts a synthesized Markdown note via stdin and registers it under the single target URL or multiple source URLs.';
9
+ static summary = 'Import agent-supplied Markdown into cache';
10
+ static description = 'Store supplied Markdown under one URL or a multi-source research note, using the same freshness and storage metadata as fetched pages.';
21
11
  static examples = [
22
12
  {
23
- description: 'import detailed research for a single URL via stdin',
24
- command: 'echo "# My Article" | <%= config.bin %> import https://example.com/docs --stdin',
13
+ description: 'import detailed research for one URL from stdin',
14
+ command: 'printf "# My Article\\n" | <%= config.bin %> import https://example.com/docs --stdin --ttl 7d',
25
15
  },
26
16
  {
27
- description: 'import research synthesized from multiple source URLs',
17
+ description: 'import a research note synthesized from multiple source URLs',
28
18
  command: 'echo "# Synthesized" | <%= config.bin %> import --stdin --topic "React docs" --source-url https://react.dev/a --source-url https://react.dev/b',
29
19
  },
30
20
  {
31
- description: 'import research from a local Markdown file',
21
+ description: 'import research from a Markdown file',
32
22
  command: '<%= config.bin %> import https://example.com/docs --file path/to/notes.md',
33
23
  },
34
24
  {
35
- description: 'import research from stdin using the standard file placeholder',
25
+ description: 'import research from stdin with the file placeholder',
36
26
  command: 'cat notes.md | <%= config.bin %> import https://example.com/docs --file -',
37
27
  },
38
28
  ];
@@ -43,7 +33,7 @@ export default class ResearchImport extends BaseCommand {
43
33
  // this, the Markdown piped for `--stdin` is swallowed into `url`, making multi-source import
44
34
  // (`import --stdin --source-url ...`) wrongly look like it also got a positional URL.
45
35
  ignoreStdin: true,
46
- description: 'the single source URL of the page (only for single-source import)',
36
+ description: 'single source URL for single-source import',
47
37
  }),
48
38
  };
49
39
  static flags = {
@@ -52,37 +42,42 @@ export default class ResearchImport extends BaseCommand {
52
42
  default: false,
53
43
  }),
54
44
  file: Flags.string({
55
- description: 'path to a Markdown file containing research notes to import, or "-" for stdin',
45
+ char: 'f',
46
+ description: 'Markdown file to import, or "-" for stdin',
56
47
  }),
57
48
  'input-format': Flags.option({
58
- description: 'format of the input provided',
49
+ description: 'input content format',
59
50
  options: ['compressed', 'detailed'],
60
51
  default: 'detailed',
61
52
  })(),
62
53
  topic: Flags.string({
63
54
  char: 't',
64
- description: 'the main topic for this research note',
55
+ description: CLI_FLAG_DESCRIPTIONS.importTopic,
65
56
  }),
66
57
  tags: Flags.string({
67
58
  char: 'g',
68
- description: 'taxonomic tags (can be repeated)',
59
+ description: CLI_FLAG_DESCRIPTIONS.importTags,
69
60
  multiple: true,
70
61
  }),
71
62
  tier: Flags.option({
72
- description: 'freshness tier policy',
63
+ description: CLI_FLAG_DESCRIPTIONS.freshnessTierPolicy,
73
64
  options: ['stable', 'standard', 'volatile'],
74
65
  default: 'standard',
75
66
  })(),
76
67
  'source-url': Flags.string({
77
- description: 'repeated source URLs for multi-source import',
68
+ description: 'source URLs for multi-source import (can be repeated)',
78
69
  multiple: true,
79
70
  }),
80
71
  ttl: Flags.string({
81
72
  char: 'l',
82
- description: 'predicted lifespan of the data (e.g. "24h", "7d")',
73
+ description: CLI_FLAG_DESCRIPTIONS.importTtl,
74
+ }),
75
+ 'dry-run': Flags.boolean({
76
+ description: 'validate import without writing cache',
77
+ default: false,
83
78
  }),
84
79
  storage: Flags.option({
85
- description: 'override where this note is cached (secrets always stored globally)',
80
+ description: CLI_FLAG_DESCRIPTIONS.importStorage,
86
81
  options: ['global', 'project'],
87
82
  })(),
88
83
  };
@@ -92,7 +87,8 @@ export default class ResearchImport extends BaseCommand {
92
87
  stdinIsInteractive() {
93
88
  return process.stdin.isTTY === true;
94
89
  }
95
- async readStdin(limitBytes = 1024 * 1024) {
90
+ // The byte limit is owned by the import service (INPUT_LIMIT_BYTES) and always passed in.
91
+ async readStdin(limitBytes) {
96
92
  return new Promise((resolve, reject) => {
97
93
  let data = '';
98
94
  let bytesRead = 0;
@@ -119,49 +115,6 @@ export default class ResearchImport extends BaseCommand {
119
115
  });
120
116
  });
121
117
  }
122
- getSourceUrls(hasMulti, multiUrls, singleUrl) {
123
- const urls = hasMulti ? multiUrls : singleUrl ? [singleUrl] : [];
124
- const normalized = urls.map((u) => {
125
- try {
126
- return normalizeUrl(u);
127
- }
128
- catch (err) {
129
- // Shared exit point: a forgotten scheme reports MISSING_URL_SCHEME like every other command,
130
- // while genuinely malformed input stays INVALID_URL.
131
- this.failInvalidUrl(u, err.message);
132
- }
133
- });
134
- return hasMulti ? normalized.sort() : normalized;
135
- }
136
- validateSourceMode(hasSingle, hasMulti, multiUrls) {
137
- if (hasSingle && hasMulti) {
138
- this.error('Cannot specify both positional <url> and --source-url flags.', {
139
- exit: 2,
140
- code: 'CONFLICTING_FLAGS',
141
- });
142
- }
143
- if (!hasSingle && !hasMulti) {
144
- this.error('Must specify either positional <url> (for single-source) or --source-url (for multi-source) import.', { exit: 2, code: 'MISSING_URL' });
145
- }
146
- if (hasMulti && !this.flags.topic) {
147
- this.error('Multi-source import requires the --topic flag.', {
148
- exit: 2,
149
- code: 'MISSING_TOPIC',
150
- });
151
- }
152
- if (!this.flags.stdin && !this.flags.file) {
153
- this.error('Either --stdin or --file <path> must be specified to import content.', {
154
- exit: 2,
155
- code: 'MISSING_INPUT',
156
- });
157
- }
158
- if (this.flags.stdin && this.flags.file) {
159
- this.error('Cannot specify both --stdin and --file <path>. Choose one.', {
160
- exit: 2,
161
- code: 'CONFLICTING_FLAGS',
162
- });
163
- }
164
- }
165
118
  fsExistsSync(filePath) {
166
119
  return fs.existsSync(filePath);
167
120
  }
@@ -171,220 +124,39 @@ export default class ResearchImport extends BaseCommand {
171
124
  fsReadFileSync(filePath) {
172
125
  return fs.readFileSync(filePath, 'utf8');
173
126
  }
174
- stdinImportSuggestions() {
175
- const bin = this.config.bin;
176
- return [
177
- `Pipe Markdown content, e.g. cat notes.md | ${bin} import <url> --stdin`,
178
- `Or use the standard stdin placeholder: cat notes.md | ${bin} import <url> --file -`,
179
- `Or read from a file: ${bin} import <url> --file notes.md`,
180
- ];
181
- }
182
- /** Non-interactive stdin that never receives data (open pipe with no writer) must not hang forever. */
183
- async readStdinWithGuard(limitBytes = 1024 * 1024) {
184
- if (this.stdinIsInteractive()) {
185
- this.error('No data piped to --stdin.', {
186
- exit: 2,
187
- code: 'MISSING_STDIN',
188
- suggestions: this.stdinImportSuggestions(),
189
- });
190
- }
191
- const timeoutMs = 1000;
192
- let rawInput = '';
193
- try {
194
- rawInput = await Promise.race([
195
- this.readStdin(limitBytes),
196
- new Promise((_, reject) => setTimeout(() => reject(new Error('STDIN_TIMEOUT')), timeoutMs)),
197
- ]);
198
- }
199
- catch (err) {
200
- if (err.message === 'STDIN_TIMEOUT') {
201
- this.error('No stdin data received. Pipe content or use --file.', {
202
- exit: 2,
203
- code: 'MISSING_STDIN',
204
- suggestions: this.stdinImportSuggestions(),
205
- });
206
- }
207
- if (err.message.includes('stdin size limit exceeded')) {
208
- this.error(err.message, { exit: 1, code: 'STDIN_TOO_LARGE' });
209
- }
210
- // Genuine stream failure reading stdin → I/O failure (exit 1).
211
- this.error(err.message, { exit: 1, code: 'IO_ERROR' });
212
- }
213
- return rawInput;
214
- }
215
- readAndValidateFile(filePath) {
216
- if (!this.fsExistsSync(filePath)) {
217
- this.error(`File does not exist: ${filePath}`, { exit: 2, code: 'FILE_NOT_FOUND' });
218
- }
219
- const stat = this.fsStatSync(filePath);
220
- if (!stat.isFile()) {
221
- this.error(`Path is not a file: ${filePath}`, { exit: 2, code: 'NOT_A_FILE' });
222
- }
223
- if (stat.size > 1024 * 1024) {
224
- this.error('File size limit exceeded (max 1 MiB).', { exit: 1, code: 'FILE_TOO_LARGE' });
225
- }
226
- const content = this.fsReadFileSync(filePath);
227
- if (!content.trim()) {
228
- this.error('Empty file content provided.', { exit: 2, code: 'EMPTY_INPUT' });
229
- }
230
- return content;
231
- }
232
- async readAndValidateInput() {
233
- const fileReadsStdin = this.flags.file === '-';
234
- if (this.flags.stdin || fileReadsStdin) {
235
- const rawInput = await this.readStdinWithGuard();
236
- if (!rawInput.trim()) {
237
- this.error('Empty stdin content provided.', { exit: 2, code: 'EMPTY_INPUT' });
238
- }
239
- return rawInput;
240
- }
241
- if (this.flags.file) {
242
- const filePath = path.resolve(this.flags.file);
243
- try {
244
- return this.readAndValidateFile(filePath);
245
- }
246
- catch (err) {
247
- // readAndValidateFile raises usage errors (missing/not-a-file/empty) via this.error,
248
- // which throw CLIErrors carrying their own exit code and message — preserve those.
249
- // Only an unexpected read failure becomes a wrapped I/O failure (exit 1).
250
- if (err instanceof Errors.CLIError)
251
- throw err;
252
- this.error(`Failed to read file: ${err.message}`, { exit: 1, code: 'IO_ERROR' });
253
- }
254
- }
255
- this.error('No input source specified.', { exit: 2, code: 'MISSING_INPUT' });
256
- }
257
- deriveImportCacheKey(hasSingle, singleNormalizedUrl, sourceUrls) {
258
- if (hasSingle) {
259
- return deriveCacheKey(singleNormalizedUrl);
260
- }
261
- const normalizedTopic = this.flags.topic.trim().toLowerCase();
262
- const combinedString = [normalizedTopic, ...sourceUrls].join('|');
263
- return createHash('sha256').update(combinedString).digest('hex');
127
+ serviceArgs() {
128
+ return { url: this.args.url };
264
129
  }
265
- buildImportArtifact(hasSingle, singleNormalizedUrl, sourceUrls, cacheKey, rawInput) {
266
- const currentTime = new Date();
267
- const tier = this.flags.tier;
268
- const ttl = this.flags.ttl || null;
269
- const { freshWindowMs } = getPolicy(tier, ttl);
270
- const staleAfterTime = new Date(currentTime.getTime() + freshWindowMs);
271
- const inputFormat = this.flags['input-format'];
272
- const detailed = sanitizePromptInjection(rawInput);
273
- // A caller-supplied compressed input is trusted as-is; only a detailed import is condensed here,
274
- // and it shares the same buildCompressed policy (structural pass + extractive fallback) as fetch.
275
- const compressed = inputFormat === 'detailed'
276
- ? buildCompressed(detailed, loadSummaryLevel(this.config.configDir, process.cwd()))
277
- : rawInput;
278
- const contentHash = createHash('sha256').update(detailed).digest('hex');
279
- const metadata = {
280
- schema_version: 1,
281
- artifact_type: hasSingle ? 'source' : 'research_note',
282
- source_url: hasSingle ? this.args.url : '',
283
- source_urls: sourceUrls,
284
- normalized_url: singleNormalizedUrl,
285
- cache_key: cacheKey,
286
- topic: this.flags.topic || null,
287
- tags: this.flags.tags || [],
288
- format_available: ['compressed', 'detailed'],
289
- tier,
290
- ttl,
291
- fetched_at: null,
292
- validated_at: currentTime.toISOString(),
293
- stale_after: staleAfterTime.toISOString(),
294
- capture_method: 'agent_supplied',
295
- extraction_status: 'agent_supplied',
296
- extraction_confidence: 'high',
297
- quality_notes: ['agent-supplied research import'],
298
- supplied_at: currentTime.toISOString(),
299
- supplied_by: null,
300
- etag: null,
301
- last_modified: null,
302
- content_hash: contentHash,
303
- token_estimate: {
304
- compressed: estimateTokens(compressed),
305
- detailed: estimateTokens(detailed),
306
- },
307
- status: 'active',
308
- site_module_id: null,
309
- docs_engine: null,
310
- docs_framework: null,
311
- source_doc_url: null,
312
- search_provider: null,
313
- parent_cache_key: null,
314
- section_anchor: null,
315
- section_heading_path: null,
130
+ serviceFlags() {
131
+ return {
132
+ stdin: this.flags.stdin,
133
+ file: this.flags.file,
134
+ inputFormat: this.flags['input-format'],
135
+ topic: this.flags.topic,
136
+ tags: this.flags.tags,
137
+ tier: this.flags.tier,
138
+ sourceUrls: this.flags['source-url'] || [],
139
+ ttl: this.flags.ttl,
140
+ storage: this.flags.storage,
316
141
  };
317
- // Auto-tag from the content when the caller supplied none, so imported notes stay searchable by
318
- // keyword without forcing manual tagging. Explicit --tags always win (handled in applyAutoTags).
319
- return applyAutoTags({
320
- metadata,
321
- summary: `Synthesized research for ${hasSingle ? singleNormalizedUrl : this.flags.topic}`,
322
- compressed,
323
- detailed,
324
- provenance: `Imported via agent-supplied research at ${currentTime.toISOString()}`,
325
- });
326
142
  }
327
143
  async run() {
328
- const hasSingle = Boolean(this.args.url);
329
- const multiUrls = this.flags['source-url'] || [];
330
- const hasMulti = multiUrls.length > 0;
331
- const ttlErr = durationFlagError('--ttl', this.flags.ttl);
332
- if (ttlErr)
333
- this.error(ttlErr, { exit: 2, code: 'INVALID_DURATION' });
334
- this.validateSourceMode(hasSingle, hasMulti, multiUrls);
335
- const rawInput = await this.readAndValidateInput();
336
- const sourceUrls = this.getSourceUrls(hasMulti, multiUrls, this.args.url);
337
- const singleNormalizedUrl = hasSingle ? sourceUrls[0] || '' : '';
338
- const cacheKey = this.deriveImportCacheKey(hasSingle, singleNormalizedUrl, sourceUrls);
339
- const artifact = this.buildImportArtifact(hasSingle, singleNormalizedUrl, sourceUrls, cacheKey, rawInput);
340
- const roots = loadStoreRoots({
341
- configDir: this.config.configDir,
342
- cwd: process.cwd(),
343
- dataDir: this.config.dataDir,
344
- flagOverride: this.flags.storage,
345
- });
346
- const dryRun = this.effectiveDryRun(false);
347
- const writeResult = writeArtifactSecurely(roots, cacheKey, artifact, { dryRun });
348
- const storagePath = getArtifactPath(writeResult.dataDir, cacheKey);
349
- if (writeResult.redirected) {
350
- this.warn(`Detected ${writeResult.secretLabel} in the imported content; stored in the global cache instead of the project to avoid committing secrets.`);
351
- }
352
- if (!this.jsonEnabled()) {
353
- this.log(dryRun
354
- ? `[dry-run] Would import research artifact.`
355
- : `Successfully imported research artifact.`);
356
- this.log(`${'Cache Key:'.padEnd(25)} ${cacheKey}`);
357
- this.log(`${'Storage Path:'.padEnd(25)} ${storagePath}`);
358
- }
359
- const inputFormat = this.flags['input-format'];
360
- return {
361
- schemaVersion: 1,
362
- command: 'import',
363
- dryRun,
364
- cache: {
365
- key: cacheKey,
366
- status: dryRun ? 'would_import' : 'imported',
367
- freshness: 'fresh',
368
- path: storagePath,
369
- storage: roots.mode,
370
- redirectedToGlobal: writeResult.redirected,
371
- },
372
- source: {
373
- url: hasSingle ? this.args.url : '',
374
- normalizedUrl: singleNormalizedUrl,
375
- captureMethod: 'agent_supplied',
376
- extractionStatus: 'agent_supplied',
377
- extractionConfidence: 'high',
378
- qualityNotes: artifact.metadata.quality_notes,
379
- fetchedAt: null,
380
- validatedAt: artifact.metadata.validated_at,
381
- staleAfter: artifact.metadata.stale_after,
144
+ const args = this.serviceArgs();
145
+ const flags = this.serviceFlags();
146
+ const io = this.cliIo();
147
+ const prepared = await prepareImportCommandService({
148
+ args,
149
+ flags,
150
+ io,
151
+ input: {
152
+ stdinIsInteractive: () => this.stdinIsInteractive(),
153
+ readStdin: (limitBytes) => this.readStdin(limitBytes),
154
+ fsExistsSync: (filePath) => this.fsExistsSync(filePath),
155
+ fsStatSync: (filePath) => this.fsStatSync(filePath),
156
+ fsReadFileSync: (filePath) => this.fsReadFileSync(filePath),
382
157
  },
383
- format: inputFormat,
384
- tokenEstimate: inputFormat === 'compressed'
385
- ? artifact.metadata.token_estimate.compressed
386
- : artifact.metadata.token_estimate.detailed,
387
- content: inputFormat === 'compressed' ? artifact.compressed : artifact.detailed,
388
- };
158
+ });
159
+ const dryRun = this.effectiveDryRun(this.flags['dry-run']);
160
+ return finishImportCommandService(prepared, dryRun, io);
389
161
  }
390
162
  }
@@ -1,4 +1,5 @@
1
1
  import { BaseCommand } from '../base-command.js';
2
+ /** Inspect a cached artifact and its section children. */
2
3
  export default class ResearchInspect extends BaseCommand<typeof ResearchInspect> {
3
4
  static id: string;
4
5
  static summary: string;
@@ -12,9 +13,14 @@ export default class ResearchInspect extends BaseCommand<typeof ResearchInspect>
12
13
  url: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
13
14
  };
14
15
  static stdoutIsPrimaryData: boolean;
16
+ protected toSuccessJson(data: unknown): Record<string, unknown>;
15
17
  run(): Promise<unknown>;
18
+ private inspectOne;
19
+ private missResult;
20
+ private findExistingNoteContaining;
21
+ private missGuidance;
22
+ private hitResult;
16
23
  private logMetadata;
17
24
  private logSections;
18
- private inspectSingleTarget;
19
25
  private findSections;
20
26
  }
@@ -1,18 +1,23 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import { BaseCommand } from '../base-command.js';
3
- import { scanCacheDirs } from '../lib/research/storage.js';
3
+ import { finalizeBatch, isBatchReadFailure, urlValidationErrorRow } from '../lib/batch.js';
4
+ import { batchSeparator, cacheMissHint, formatCacheTargetHeader } from '../lib/cache-view.js';
5
+ import { getArtifactPath, scanCacheDirs } from '../lib/research/storage.js';
6
+ import { artifactMatchesUrlFilter } from '../lib/research/url.js';
4
7
  import { colors } from '../lib/color.js';
8
+ import { formatHumanField } from '../lib/cli-presentation.js';
9
+ /** Inspect a cached artifact and its section children. */
5
10
  export default class ResearchInspect extends BaseCommand {
6
11
  static id = 'inspect';
7
- static summary = 'Inspect cached research metadata for a URL.';
8
- static description = 'Inspects local storage and displays full frontmatter metadata for the URL (without printing the content body).';
12
+ static summary = 'Inspect cached research metadata for a URL';
13
+ static description = 'Show frontmatter metadata and section children for cached URLs without printing content.';
9
14
  static examples = [
10
15
  {
11
- description: 'inspect cached research metadata for a specific URL',
16
+ description: 'inspect cached metadata for a URL',
12
17
  command: '<%= config.bin %> <%= command.id %> https://example.com/docs',
13
18
  },
14
19
  {
15
- description: 'output the full metadata as JSON for use by other tools',
20
+ description: 'inspect cached metadata as JSON',
16
21
  command: '<%= config.bin %> <%= command.id %> https://example.com/docs --json',
17
22
  },
18
23
  ];
@@ -20,75 +25,121 @@ export default class ResearchInspect extends BaseCommand {
20
25
  static args = {
21
26
  url: Args.string({
22
27
  required: true,
23
- description: 'the URL to inspect',
28
+ description: 'URL to inspect',
24
29
  }),
25
30
  };
26
31
  static stdoutIsPrimaryData = true;
32
+ toSuccessJson(data) {
33
+ return this.batchReadSuccessJson(data);
34
+ }
27
35
  async run() {
28
- const urls = this.parsedArgv;
29
- const results = [];
30
- const missingUrls = [];
31
- for (const url of urls) {
32
- const target = this.resolveResearchTargetOrFail(url);
33
- if (!target.located) {
34
- missingUrls.push(target.normalizedUrl);
35
- continue;
36
- }
37
- results.push(this.inspectSingleTarget(target, urls.length > 1));
38
- }
39
- if (missingUrls.length > 0) {
40
- const firstMissing = missingUrls[0];
41
- const suggestions = missingUrls.map((u) => `Fetch and cache it first: ${this.config.bin} ${u}`);
42
- this.error(`No cached research found for URL: ${firstMissing}` +
43
- (missingUrls.length > 1 ? ` and ${missingUrls.length - 1} other URLs` : ''), {
44
- exit: 1,
45
- code: 'CACHE_MISS',
46
- suggestions,
47
- });
48
- }
49
- return urls.length === 1 ? results[0] : results;
36
+ const results = this.mapUrlsAllowingBatchErrors(this.parsedArgv, (url) => this.inspectOne(url), urlValidationErrorRow);
37
+ return finalizeBatch(results, isBatchReadFailure);
50
38
  }
51
- logMetadata(metadata) {
52
- this.log(colors.cyan(`--- Metadata ---`));
53
- for (const [key, val] of Object.entries(metadata)) {
54
- const paddedKey = colors.cyan((key + ':').padEnd(Math.max(25, key.length + 2)));
55
- if (typeof val === 'object' && val !== null) {
56
- this.log(`${paddedKey} ${colors.bold(JSON.stringify(val))}`);
57
- }
58
- else {
59
- this.log(`${paddedKey} ${colors.bold(String(val))}`);
39
+ inspectOne(url) {
40
+ const target = this.resolveResearchTargetOrFail(url);
41
+ return target.located ? this.hitResult(target) : this.missResult(target);
42
+ }
43
+ missResult(target) {
44
+ const artifactPath = getArtifactPath(target.roots.writeRoot, target.cacheKey);
45
+ const existingNote = this.findExistingNoteContaining(target.roots.readRoots, target.normalizedUrl);
46
+ if (!this.jsonEnabled()) {
47
+ for (const line of formatCacheTargetHeader(target, [['Status', colors.red('miss')]], artifactPath)) {
48
+ this.log(line);
60
49
  }
50
+ this.warn(this.missGuidance(target.normalizedUrl, existingNote));
51
+ const sep = batchSeparator(this.parsedArgv.length > 1);
52
+ if (sep)
53
+ this.log(sep);
61
54
  }
55
+ return {
56
+ cacheKey: target.cacheKey,
57
+ cachePath: artifactPath,
58
+ normalizedUrl: target.normalizedUrl,
59
+ status: 'miss',
60
+ metadata: null,
61
+ sections: [],
62
+ partOfExistingNote: existingNote,
63
+ };
62
64
  }
63
- logSections(sections) {
64
- this.log(colors.cyan(`--- Sections (${sections.length}) ---`));
65
- for (const s of sections) {
66
- this.log(`${colors.cyan(s.headingPath || '')} [${colors.yellow(s.anchor || '')}] (${colors.magenta(String(s.tokenEstimate.detailed || 0))} tokens) ${colors.gray(s.cacheKey)}`);
67
- }
65
+ // A URL with no cache key of its own can still be a secondary `--source-url` of an already
66
+ // imported multi-source research_note (those key off topic+content, not any one URL — see
67
+ // docs/reference/cache-protocol.md). Without this check, the generic "fetch and cache it" miss
68
+ // hint would steer the caller into creating an unrelated duplicate entry instead of finding the
69
+ // note that already covers this URL (discoverable today via `list --url`).
70
+ //
71
+ // ponytail: `status` and `fetch` have the same blind spot on a miss (they'd still suggest a
72
+ // fetch/refetch for a URL that's really a secondary source of an existing note) but aren't
73
+ // patched here — `status` is meant to stay a cheap pre-flight check, and `fetch`'s miss path
74
+ // already does real work (an actual network fetch) rather than just reporting a status, so
75
+ // adding a full cache scan to either changes their cost profile more than this fix's scope
76
+ // warrants. Extend this same scan to those commands if the gap proves costly in practice.
77
+ findExistingNoteContaining(readRoots, normalizedUrl) {
78
+ const matches = scanCacheDirs(readRoots, (artifact) => {
79
+ const meta = artifact.metadata;
80
+ if (meta.status !== 'active')
81
+ return null;
82
+ if (!artifactMatchesUrlFilter(meta, normalizedUrl))
83
+ return null;
84
+ return {
85
+ cacheKey: meta.cache_key,
86
+ artifactType: meta.artifact_type,
87
+ topic: meta.topic,
88
+ sourceUrls: meta.source_urls,
89
+ };
90
+ }, { persistIndex: !this.readOnly });
91
+ return matches[0] ?? null;
68
92
  }
69
- inspectSingleTarget(target, showSeparator) {
70
- const { cacheKey, located, roots } = target;
93
+ missGuidance(normalizedUrl, existingNote) {
94
+ if (!existingNote)
95
+ return cacheMissHint(this.config.bin, normalizedUrl);
96
+ return [
97
+ `${normalizedUrl} has no cache entry of its own, but it is a source of an existing ${existingNote.artifactType} (cache key: ${existingNote.cacheKey}).`,
98
+ `Find it with: ${this.config.bin} list --url "${normalizedUrl}"`,
99
+ ].join('\n');
100
+ }
101
+ hitResult(target) {
102
+ const { cacheKey, located, roots, normalizedUrl } = target;
71
103
  const cached = located.artifact;
72
104
  const artifactPath = located.path;
73
105
  const sections = this.findSections(roots.readRoots, cacheKey);
74
106
  if (!this.jsonEnabled()) {
75
- this.log(`${colors.cyan('Cache Key:'.padEnd(25))} ${colors.bold(cacheKey)}`);
76
- this.log(`${colors.cyan('Cache Path:'.padEnd(25))} ${colors.gray(artifactPath)}`);
107
+ for (const line of formatCacheTargetHeader(target, [], artifactPath)) {
108
+ this.log(line);
109
+ }
77
110
  this.logMetadata(cached.metadata);
78
- if (sections.length) {
111
+ if (sections.length)
79
112
  this.logSections(sections);
80
- }
81
- if (showSeparator) {
82
- this.log('='.repeat(40));
83
- }
113
+ const sep = batchSeparator(this.parsedArgv.length > 1);
114
+ if (sep)
115
+ this.log(sep);
84
116
  }
85
117
  return {
86
118
  cacheKey,
87
119
  cachePath: artifactPath,
120
+ normalizedUrl,
121
+ status: 'hit',
88
122
  metadata: cached.metadata,
89
123
  sections,
90
124
  };
91
125
  }
126
+ logMetadata(metadata) {
127
+ this.log(colors.cyan(`--- Metadata ---`));
128
+ for (const [key, val] of Object.entries(metadata)) {
129
+ if (typeof val === 'object' && val !== null) {
130
+ this.log(formatHumanField(key, colors.bold(JSON.stringify(val))));
131
+ }
132
+ else {
133
+ this.log(formatHumanField(key, colors.bold(String(val))));
134
+ }
135
+ }
136
+ }
137
+ logSections(sections) {
138
+ this.log(colors.cyan(`--- Sections (${sections.length}) ---`));
139
+ for (const s of sections) {
140
+ this.log(`${colors.cyan(s.headingPath || '')} [${colors.yellow(s.anchor || '')}] (${colors.magenta(String(s.tokenEstimate.detailed || 0))} tokens) ${colors.gray(s.cacheKey)}`);
141
+ }
142
+ }
92
143
  // Section children link back via parent_cache_key; list the active ones for this page (T-22).
93
144
  findSections(readRoots, parentKey) {
94
145
  return scanCacheDirs(readRoots, (artifact) => {
@@ -101,6 +152,6 @@ export default class ResearchInspect extends BaseCommand {
101
152
  headingPath: meta.section_heading_path,
102
153
  tokenEstimate: meta.token_estimate,
103
154
  };
104
- }).sort((a, b) => (a.headingPath ?? '').localeCompare(b.headingPath ?? ''));
155
+ }, { persistIndex: !this.readOnly }).sort((a, b) => (a.headingPath ?? '').localeCompare(b.headingPath ?? ''));
105
156
  }
106
157
  }