agentlinkops 0.6.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.mcp.json +0 -0
- package/CHANGELOG.md +17 -0
- package/LICENSE +0 -0
- package/NOTICE +0 -0
- package/README.md +0 -0
- package/bin/agentlinkops.mjs +0 -0
- package/bin/linktrail.mjs +0 -0
- package/cli/adapters/csv.js +0 -0
- package/cli/adapters/sqlite-crm.js +0 -0
- package/cli/adapters/suppliers.js +0 -0
- package/cli/admission.js +130 -0
- package/cli/agent-setup.js +1014 -101
- package/cli/agentlinkops.mjs +0 -0
- package/cli/args.js +0 -0
- package/cli/catalog.js +12837 -4694
- package/cli/check.js +0 -0
- package/cli/citation.js +181 -32
- package/cli/client.js +1 -1
- package/cli/commands.js +3 -3
- package/cli/config.js +2 -1
- package/cli/connection.js +0 -0
- package/cli/context-mcp.js +0 -0
- package/cli/context.js +0 -0
- package/cli/delivery.js +0 -0
- package/cli/disavow.js +54 -0
- package/cli/doctor.js +7 -8
- package/cli/env.js +0 -0
- package/cli/fleet.js +0 -0
- package/cli/gsc-browser.js +0 -0
- package/cli/import.js +0 -0
- package/cli/index-observations.js +89 -0
- package/cli/ledger.js +7 -1
- package/cli/lifecycle.js +40 -0
- package/cli/linktrail.mjs +0 -0
- package/cli/local-result.js +0 -0
- package/cli/main.js +99 -7
- package/cli/mirror.js +0 -0
- package/cli/mix.js +0 -0
- package/cli/platforms.js +0 -0
- package/cli/receipt-performance.js +0 -0
- package/cli/receipts.js +0 -0
- package/cli/report.js +79 -6
- package/cli/setup-plan.js +0 -0
- package/cli/skill.js +41 -4
- package/cli/state.js +0 -0
- package/cli/status.js +0 -0
- package/cli/sync.js +23 -1
- package/package.json +3 -2
- package/references/citation-panels.md +26 -0
- package/references/cli.md +0 -0
- package/references/disavow.md +0 -0
- package/references/discovery-handoff.md +32 -0
- package/references/recipes/catalog.json +148 -0
- package/references/recipes/command-notes.md +9 -0
- package/references/recipes/placement-reconciliation.md +41 -0
- package/references/recipes/qualified-campaign-handoff.md +56 -0
- package/references/recipes/site-context-brief.md +25 -0
- package/references/recipes/sourced-linkable-asset.md +25 -0
- package/references/reports.md +0 -0
- package/scripts/agentlinkops.py +0 -0
- package/scripts/disavow.py +0 -0
- package/scripts/linktrail.py +0 -0
- package/scripts/migrations/001_crm.sql +0 -0
- package/scripts/migrations/002_cloud_events.sql +0 -0
- package/scripts/migrations/003_disavow.sql +0 -0
- package/scripts/migrations/004_placement_costs.sql +0 -0
- package/scripts/report.py +0 -0
- package/shared/admission-contract.js +16 -0
- package/shared/admission-report-contract.js +8 -0
- package/shared/agent-clients.js +0 -0
- package/shared/citation-evidence-contract.js +27 -0
- package/shared/disavow-contract.js +18 -0
- package/shared/index-observation-contract.js +14 -0
- package/shared/lifecycle-contract.js +22 -0
- package/shared/lifecycle-money.js +24 -0
- package/shared/observation-event.js +12 -1
- package/shared/opportunity-record.js +259 -0
- package/shared/response-contract.js +2 -0
- package/shared/search-connection-contract.js +0 -0
- package/skills/agentlinkops-assets/SKILL.md +2 -0
- package/skills/agentlinkops-assets/agents/openai.yaml +0 -0
- package/skills/agentlinkops-campaigns/SKILL.md +5 -1
- package/skills/agentlinkops-campaigns/agents/openai.yaml +0 -0
- package/skills/agentlinkops-connect/SKILL.md +8 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +0 -0
- package/skills/agentlinkops-connect/references/agentlinkops.md +9 -5
- package/skills/agentlinkops-crm/SKILL.md +2 -0
- package/skills/agentlinkops-crm/agents/openai.yaml +0 -0
- package/skills/agentlinkops-discovery/SKILL.md +17 -0
- package/skills/content-defingerprinting/SKILL.md +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -0
- package/skills/internal-linking-optimizer/SKILL.md +0 -0
- package/skills/internal-linking-optimizer/references/source-prompt.md +0 -0
- package/skills/pre-publish-review/SKILL.md +0 -0
- package/skills/source-cited-content-builder/SKILL.md +0 -0
- package/skills/source-cited-content-builder/references/source-prompt.md +0 -0
- package/skills/source-cited-humanizer/SKILL.md +0 -0
- package/src/citations/adapter.js +102 -21
- package/src/citations/alert.js +38 -13
- package/src/citations/browser/engine.js +145 -50
- package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
- package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
- package/src/citations/browser/gen/lib/browser/display.js +0 -0
- package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
- package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
- package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
- package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
- package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
- package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
- package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
- package/src/citations/browser/gen/utils/agent-constants.js +0 -0
- package/src/citations/browser/providers.js +3 -1
- package/src/citations/browser/shims/errors.js +0 -0
- package/src/citations/browser/shims/types.js +0 -0
- package/src/citations/browser/shims/utils.js +1 -0
- package/src/citations/contract.js +55 -9
- package/src/citations/inventory.js +106 -0
- package/src/citations/login.js +122 -0
- package/src/citations/match.js +12 -0
- package/src/citations/panel-builder.js +44 -39
- package/src/citations/report.js +88 -21
- package/src/citations/runner.js +59 -31
- package/src/citations/stats.js +133 -61
- package/src/citations/sync.js +131 -0
- package/src/citations/watch.js +57 -77
- package/src/competitors/contract.js +0 -0
- package/src/competitors/domain-mix.js +0 -0
- package/src/context/ga4.js +0 -0
- package/src/context/gsc.js +0 -0
- package/src/context/site-profile.js +0 -0
- package/src/context/util.js +0 -0
- package/src/context-tool-registry.js +0 -0
- package/src/corpus/acquire.js +47 -8
- package/src/corpus/seeds.js +132 -19
- package/src/discovery/contract.js +0 -0
- package/src/discovery/import.js +0 -0
- package/src/evidence-reference.js +112 -0
- package/src/index-observation.js +161 -0
- package/src/locator.js +112 -0
- package/src/tool-views.js +6 -2
- package/src/verifier/fetch.js +42 -6
- package/src/verifier/index.js +0 -0
- package/src/verifier/observation-kind.js +0 -0
- package/src/verifier/parser.js +0 -0
- package/src/verifier/readiness.js +0 -0
- package/src/verifier/render-evidence.js +0 -0
- package/src/verifier/robots.js +0 -0
- package/src/verifier/transition.js +0 -0
- package/src/verifier/url.js +0 -0
- package/templates/SITE.template.md +0 -0
- package/templates/campaigns.v1.json +0 -0
- package/src/citations/browser/gen/core/providers/_shared/google.js +0 -4
- package/src/citations/browser/gen/core/providers/types.js +0 -0
- package/src/citations/browser/gen/lib/browser/playwrightCompat.js +0 -321
- package/src/citations/browser/gen/lib/browser/proxy/forwarder.js +0 -251
- package/src/citations/browser/gen/lib/browser/runtimeTypes.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/assertNotBlocked.js +0 -54
- package/src/citations/browser/gen/lib/input/editor/findSendButton.js +0 -21
- package/src/citations/browser/gen/lib/input/response/getText.js +0 -10
- package/src/citations/fixtures/dataforseo-aio.json +0 -82
package/cli/check.js
CHANGED
|
File without changes
|
package/cli/citation.js
CHANGED
|
@@ -12,12 +12,17 @@ import { resolve, join } from 'node:path';
|
|
|
12
12
|
|
|
13
13
|
import { parseArgs } from './args.js';
|
|
14
14
|
import { loadPanel, runEpoch } from '../src/citations/runner.js';
|
|
15
|
-
import { engineIdentity } from '../src/citations/contract.js';
|
|
15
|
+
import { engineIdentity, panelLocaleContext } from '../src/citations/contract.js';
|
|
16
16
|
import { createMockEngine, createPerplexityEngine, EngineError } from '../src/citations/adapter.js';
|
|
17
17
|
import { describeChange } from '../src/citations/stats.js';
|
|
18
18
|
|
|
19
19
|
const USAGE = `agentlinkops citation — AI citation watches in this repository
|
|
20
20
|
|
|
21
|
+
agentlinkops citation panel --domain HOST --brand NAME [--topic TOPIC]
|
|
22
|
+
[--prompt QUESTION | --research FILE --limit N] [--competitors FILE]
|
|
23
|
+
[--engine mock|chatgpt:web-own-browser|google-aio]
|
|
24
|
+
[--locale en-US] [--country US] [--out PANEL.json]
|
|
25
|
+
write editable prompt suggestions; mock is the default
|
|
21
26
|
agentlinkops citation run PANEL.json [--dir DIR] [--samples N] [--max-usd USD] [--json]
|
|
22
27
|
run one fixed-n epoch over a panel; evidence and
|
|
23
28
|
tallies land under .agentlinkops/citations/
|
|
@@ -25,52 +30,137 @@ const USAGE = `agentlinkops citation — AI citation watches in this repository
|
|
|
25
30
|
measure the consumer UI through Camoufox
|
|
26
31
|
(dedicated account, personal cadence; evidence
|
|
27
32
|
includes a screenshot of every answer)
|
|
28
|
-
agentlinkops citation
|
|
33
|
+
agentlinkops citation sweep PANEL.json --max-usd USD --out INVENTORY.json [--dir DIR]
|
|
34
|
+
run a bounded epoch and freeze its retained evidence
|
|
35
|
+
agentlinkops citation inventory PANEL.json --out INVENTORY.json [--epoch ID] [--dir DIR]
|
|
36
|
+
freeze an existing panel epoch without new calls
|
|
37
|
+
agentlinkops citation gap INVENTORY.json --out GAP.json
|
|
38
|
+
compare mentions and citations within this dataset
|
|
39
|
+
agentlinkops citation export-sources INVENTORY.json --niche ID --niche-label TEXT
|
|
40
|
+
--consent-ref REF --attest-owned --out SOURCES.json
|
|
41
|
+
export held library candidates; no publication
|
|
42
|
+
agentlinkops citation report PANEL.json [--out FILE] [--dir DIR] [--last N]
|
|
29
43
|
render the latest epoch as a self-contained
|
|
30
44
|
HTML report: trends, screenshots inline,
|
|
31
45
|
Wilson intervals, honest classifications
|
|
32
46
|
agentlinkops citation watch PANEL.json --cadence weekly|daily|hourly|monthly
|
|
47
|
+
[--webhook URL] [--out REPORT.html]
|
|
33
48
|
register a cron entry that runs the epoch
|
|
34
49
|
and generates the report on schedule
|
|
35
50
|
agentlinkops citation watch --list list registered citation watches
|
|
36
51
|
agentlinkops citation watch --remove NAME remove a registered watch
|
|
37
|
-
agentlinkops citation alert PANEL.json
|
|
52
|
+
agentlinkops citation alert PANEL.json [--webhook URL --report REPORT.html]
|
|
53
|
+
check the latest epoch for declines;
|
|
38
54
|
logs an alert line if any cell declined
|
|
55
|
+
agentlinkops citation login ENGINE open a browser, log in once, save the session
|
|
56
|
+
(the engine adapter uses it on every run)
|
|
57
|
+
agentlinkops citation logout ENGINE remove a saved session
|
|
58
|
+
agentlinkops citation sessions list saved engine sessions
|
|
59
|
+
agentlinkops citation sync [--dir DIR] [--evidence] push local epoch rows to the hosted
|
|
60
|
+
API (requires agentlinkops connect first)
|
|
61
|
+
--evidence also uploads retained answer text;
|
|
62
|
+
screenshot bytes remain local
|
|
63
|
+
agentlinkops citation doctor check local installation and browser dependencies
|
|
39
64
|
agentlinkops citation help this help
|
|
40
65
|
|
|
41
66
|
Panel (JSON): targets (domain or url, brand, aliases), prompts, engines
|
|
42
67
|
([{ "engine": "mock" | "perplexity", "model": "…" }]), optional samples and maxUsd.
|
|
43
|
-
Live engines need
|
|
68
|
+
Live engines need their configured credentials or a usable browser session; the mock
|
|
44
69
|
engine runs everything at zero cost. Every run states its estimated spend, and the
|
|
45
|
-
cap
|
|
70
|
+
cap reserves estimated cost before each call. A supplier charge above that estimate
|
|
71
|
+
is recorded as an overrun and stops subsequent calls.
|
|
46
72
|
|
|
47
73
|
Output language: citation RATE with a Wilson interval and n, classified against the
|
|
48
74
|
previous epoch as declined, grown or not_distinguishable — never "removed", never
|
|
49
75
|
"stable", and insufficient_data when n is too thin to interpret.`;
|
|
50
76
|
|
|
51
77
|
export async function citationMain(argv = [], { cwd = process.cwd(), out = console.log, err = console.error, env = process.env } = {}) {
|
|
52
|
-
const args = parseArgs(argv);
|
|
78
|
+
const args = parseArgs(argv, ['prompt', 'alias']);
|
|
53
79
|
const command = args._[0] ?? 'help';
|
|
54
80
|
if (command === 'help' || args.help) { out(USAGE); return 0; }
|
|
81
|
+
if (command === 'doctor') {
|
|
82
|
+
const { doctorMain } = await import('./doctor.js');
|
|
83
|
+
return doctorMain(args._.slice(1), { cwd, out, err, env });
|
|
84
|
+
}
|
|
85
|
+
if (command === 'panel') {
|
|
86
|
+
const { buildStarterPanel, selectResearchPrompts } = await import('../src/citations/panel-builder.js');
|
|
87
|
+
const { readFile, writeFile } = await import('node:fs/promises');
|
|
88
|
+
try {
|
|
89
|
+
if (args.research && args.prompt) throw new Error('Use either --research or --prompt');
|
|
90
|
+
const researched = args.research ? selectResearchPrompts(JSON.parse(await readFile(resolve(cwd, args.research), 'utf8')), { limit: args.limit === undefined ? 50 : Number(args.limit) }) : undefined;
|
|
91
|
+
const competitors = args.competitors ? JSON.parse(await readFile(resolve(cwd, args.competitors), 'utf8')) : [];
|
|
92
|
+
const selected = args.engine ?? 'mock';
|
|
93
|
+
const [engine, via] = selected.split(':');
|
|
94
|
+
if (!['mock', 'chatgpt', 'grok', 'duck', 'google-aio'].includes(engine) || (via && via !== 'web-own-browser')) throw new Error('unsupported panel engine');
|
|
95
|
+
if (['chatgpt', 'grok'].includes(engine) && via !== 'web-own-browser') throw new Error('browser engines require :web-own-browser');
|
|
96
|
+
const panel = buildStarterPanel({ domain: args.domain, brand: args.brand, topic: args.topic, aliases: args.alias ?? [],
|
|
97
|
+
competitors, engines: [{ engine, via: via ? 'browser' : 'api' }], locale: args.locale ?? 'en-US', country: args.country,
|
|
98
|
+
samples: args.samples === undefined ? 10 : Number(args.samples), maxUsd: args['max-usd'] === undefined ? 1 : Number(args['max-usd']),
|
|
99
|
+
prompts: researched ?? args.prompt?.map((text, index) => ({ id: `p${index + 1}`, text })),
|
|
100
|
+
});
|
|
101
|
+
const json = JSON.stringify(panel, null, 2) + '\n';
|
|
102
|
+
if (args.out) { await writeFile(resolve(cwd, args.out), json, { flag: 'wx' }); out(`panel written: ${args.out}`); }
|
|
103
|
+
else out(json);
|
|
104
|
+
err('Prompts are editable suggestions, not observed search demand. Review them before using a live engine.');
|
|
105
|
+
return 0;
|
|
106
|
+
} catch (cause) { err(cause.code === 'EEXIST' ? 'panel output already exists; choose another path' : cause.message); return 2; }
|
|
107
|
+
}
|
|
108
|
+
if (['sweep', 'inventory', 'gap', 'export-sources'].includes(command)) {
|
|
109
|
+
const { readFile, open, unlink } = await import('node:fs/promises');
|
|
110
|
+
const { freezeCitationInventory, citationGap, exportCitationSources } = await import('../src/citations/inventory.js');
|
|
111
|
+
let output;
|
|
112
|
+
try {
|
|
113
|
+
if (!args._[1] || typeof args.out !== 'string') throw new Error(`${command} requires an input file and --out FILE`);
|
|
114
|
+
if (command === 'sweep' && (args['max-usd'] === undefined || !Number.isFinite(Number(args['max-usd'])) || Number(args['max-usd']) <= 0)) throw new Error('sweep requires an explicit positive --max-usd ceiling');
|
|
115
|
+
// Reserve an exclusive output before a sweep can incur any measurement cost.
|
|
116
|
+
output = await open(resolve(cwd, args.out), 'wx', 0o600);
|
|
117
|
+
let result; let code = 0;
|
|
118
|
+
if (command === 'gap' || command === 'export-sources') {
|
|
119
|
+
const inventory = JSON.parse(await readFile(resolve(cwd, args._[1]), 'utf8'));
|
|
120
|
+
result = command === 'gap' ? citationGap(inventory) : await exportCitationSources(inventory, {
|
|
121
|
+
niche: { id: args.niche, label: args['niche-label'] }, consentRef: args['consent-ref'], excludedInputsAttested: args['attest-owned'] === true,
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
const panel = await loadPanel(resolve(cwd, args._[1]));
|
|
125
|
+
let epochId = args.epoch;
|
|
126
|
+
if (command === 'sweep') {
|
|
127
|
+
panel.maxUsd = Number(args['max-usd']);
|
|
128
|
+
let run;
|
|
129
|
+
code = await citationMain(['run', args._[1], '--dir', args.dir ?? '.agentlinkops', '--max-usd', String(args['max-usd']), '--json'], { cwd, env, err, out: value => { run = JSON.parse(value); } });
|
|
130
|
+
if (!run?.epochId) throw new Error('Sweep did not produce an epoch');
|
|
131
|
+
epochId = run.epochId;
|
|
132
|
+
}
|
|
133
|
+
result = await freezeCitationInventory({ dir: resolve(cwd, args.dir ?? '.agentlinkops'), panel, epochId });
|
|
134
|
+
}
|
|
135
|
+
await output.writeFile(JSON.stringify(result, null, 2) + '\n'); await output.close(); output = null;
|
|
136
|
+
out(`${command} written: ${args.out}`); return code;
|
|
137
|
+
} catch (cause) {
|
|
138
|
+
if (output) { await output.close(); await unlink(resolve(cwd, args.out)).catch(() => {}); }
|
|
139
|
+
err(cause.code === 'EEXIST' ? 'Output already exists; choose another path' : cause.message); return 2;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
55
142
|
if (command === 'alert') {
|
|
56
143
|
const panelPath = args._[1];
|
|
57
144
|
if (!panelPath) { err('citation alert needs a panel file'); return 2; }
|
|
58
145
|
const { readFile } = await import('node:fs/promises');
|
|
59
146
|
const dir = resolve(cwd, args.dir ?? '.agentlinkops');
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (rows.length > 0) latestEpochId = rows[rows.length - 1].epoch_id;
|
|
65
|
-
} catch { /* no epochs */ }
|
|
66
|
-
if (!latestEpochId) { out('no epochs to check'); return 0; }
|
|
147
|
+
const { latestPanelEpoch } = await import('../src/citations/report.js');
|
|
148
|
+
const panel = await loadPanel(resolve(cwd, panelPath));
|
|
149
|
+
const cells = await latestPanelEpoch({ dir, panel });
|
|
150
|
+
if (!cells) { out('no epochs to check for this panel'); return 0; }
|
|
67
151
|
const { shouldAlert } = await import('../src/citations/alert.js');
|
|
68
|
-
const epochsText = await readFile(join(dir, 'citations-epochs.jsonl'), 'utf8');
|
|
69
|
-
const cells = epochsText.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l)).filter((r) => r.epoch_id === latestEpochId);
|
|
70
152
|
if (shouldAlert(cells)) {
|
|
71
153
|
const declined = cells.filter((c) => c.classification === 'declined');
|
|
72
154
|
err(`ALERT: ${declined.length} cell${declined.length === 1 ? '' : 's'} declined:`);
|
|
73
|
-
for (const c of declined) err(` ${c.cell_id}: rate ${c.rate
|
|
155
|
+
for (const c of declined) err(` ${c.cell_id}: rate ${c.rate?.toFixed(2) ?? 'unknown'} (was ${c.baseline?.rate?.toFixed(2) ?? 'n/a'})`);
|
|
156
|
+
if (args.webhook) {
|
|
157
|
+
const { sendAlertWebhook } = await import('../src/citations/alert.js');
|
|
158
|
+
try {
|
|
159
|
+
const htmlPath = resolve(cwd, args.report ?? join(dir, 'citations', 'report.html'));
|
|
160
|
+
await sendAlertWebhook({ endpoint: args.webhook, epochId: cells[0].epoch_id, cells, htmlPath });
|
|
161
|
+
out('citation decline report delivered');
|
|
162
|
+
} catch { err('citation webhook delivery failed'); return 2; }
|
|
163
|
+
}
|
|
74
164
|
return 1; // exit 1 = actionable, same convention as link checks
|
|
75
165
|
}
|
|
76
166
|
out('no declines detected');
|
|
@@ -93,7 +183,7 @@ export async function citationMain(argv = [], { cwd = process.cwd(), out = conso
|
|
|
93
183
|
if (!panelPath) { err('citation watch needs a panel file (or --list / --remove)'); return 2; }
|
|
94
184
|
const cadence = args.cadence ?? 'weekly';
|
|
95
185
|
try {
|
|
96
|
-
const result = await registerWatch({ panelPath, cadence });
|
|
186
|
+
const result = await registerWatch({ panelPath: resolve(cwd, panelPath), cadence, dir: resolve(cwd, args.dir ?? '.agentlinkops'), out: args.out ? resolve(cwd, args.out) : undefined, webhook: args.webhook });
|
|
97
187
|
out(`citation watch registered: ${result.panelPath}`);
|
|
98
188
|
out(`cadence: ${result.cadence} (${result.next})`);
|
|
99
189
|
out(`cron: ${result.cronSpec}`);
|
|
@@ -103,22 +193,81 @@ export async function citationMain(argv = [], { cwd = process.cwd(), out = conso
|
|
|
103
193
|
return 2;
|
|
104
194
|
}
|
|
105
195
|
}
|
|
196
|
+
if (command === 'login') {
|
|
197
|
+
const engineName = args._[1];
|
|
198
|
+
if (!engineName) { err('citation login needs an engine name (e.g. chatgpt)'); return 2; }
|
|
199
|
+
const { loginEngine } = await import('../src/citations/login.js');
|
|
200
|
+
try {
|
|
201
|
+
const result = await loginEngine({ engineName });
|
|
202
|
+
out(`session saved: ${result.sessionPath}`);
|
|
203
|
+
out(`cookies: ${result.cookies}, origins: ${result.origins}, login detected: ${result.loggedIn}`);
|
|
204
|
+
return 0;
|
|
205
|
+
} catch (cause) { err(cause.message); return 2; }
|
|
206
|
+
}
|
|
207
|
+
if (command === 'logout') {
|
|
208
|
+
const engineName = args._[1];
|
|
209
|
+
if (!engineName) { err('citation logout needs an engine name'); return 2; }
|
|
210
|
+
const { logoutEngine } = await import('../src/citations/login.js');
|
|
211
|
+
const result = await logoutEngine({ engineName });
|
|
212
|
+
out(result.removed ? `session removed: ${engineName}` : `no session found for ${engineName}`);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
if (command === 'sessions') {
|
|
216
|
+
const { listSessions } = await import('../src/citations/login.js');
|
|
217
|
+
const sessions = await listSessions();
|
|
218
|
+
if (sessions.length === 0) { out('no saved sessions'); return 0; }
|
|
219
|
+
for (const s of sessions) out(s);
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
if (command === 'sync') {
|
|
223
|
+
const dir = resolve(cwd, args.dir ?? '.agentlinkops');
|
|
224
|
+
const { createClient } = await import('./client.js');
|
|
225
|
+
const { cloudConnection } = await import('./connection.js');
|
|
226
|
+
const { readFile } = await import('node:fs/promises');
|
|
227
|
+
const { join } = await import('node:path');
|
|
228
|
+
try {
|
|
229
|
+
const { loadConfig } = await import('./config.js');
|
|
230
|
+
const config = await loadConfig({ cwd });
|
|
231
|
+
const connection = cloudConnection(config, env);
|
|
232
|
+
if (!connection.token) { err('citation sync needs a cloud connection (run agentlinkops connect)'); return 2; }
|
|
233
|
+
const { syncEpochs } = await import('../src/citations/sync.js');
|
|
234
|
+
const client = createClient(connection);
|
|
235
|
+
const result = await syncEpochs({
|
|
236
|
+
dir,
|
|
237
|
+
api: async (path, init) => client.callCommand(path.replace('/v1/commands/', ''), init.body),
|
|
238
|
+
workspaceId: connection.workspaceId,
|
|
239
|
+
projectId: connection.projectId,
|
|
240
|
+
});
|
|
241
|
+
out(`synced: ${result.synced}/${result.total} epoch rows${result.errors ? `, ${result.errors} errors` : ''}`);
|
|
242
|
+
if (result.normalized_rates) out(`${result.normalized_rates} legacy rates normalized: ${result.normalization}`);
|
|
243
|
+
if (args.evidence) {
|
|
244
|
+
const { syncEvidence } = await import('../src/citations/sync.js');
|
|
245
|
+
const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
246
|
+
const evidence = await syncEvidence({ dir, projectId: connection.projectId, cliVersion: manifest.version,
|
|
247
|
+
api: async (path, init) => client.callCommand(path.replace('/v1/commands/', ''), init.body) });
|
|
248
|
+
out(`evidence synced: ${evidence.synced}/${evidence.total}, ${evidence.skipped} skipped, ${evidence.errors} errors; screenshot bytes remain local`);
|
|
249
|
+
for (const [reason, count] of Object.entries(evidence.reasons)) out(`${reason}: ${count}`);
|
|
250
|
+
if (evidence.errors || evidence.skipped) return 2;
|
|
251
|
+
}
|
|
252
|
+
return result.errors ? 1 : 0;
|
|
253
|
+
} catch (cause) { err(cause.message); return 2; }
|
|
254
|
+
}
|
|
106
255
|
if (command === 'report') {
|
|
107
256
|
const panelPath = args._[1];
|
|
108
257
|
if (!panelPath) { err('citation report needs a panel file'); return 2; }
|
|
109
258
|
const dir = resolve(cwd, args.dir ?? '.agentlinkops');
|
|
110
259
|
const { generateReport } = await import('../src/citations/report.js');
|
|
111
260
|
const { readFile, writeFile: wf } = await import('node:fs/promises');
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (!
|
|
120
|
-
const
|
|
121
|
-
const html = await generateReport({ dir, epochId: latestEpochId, panel });
|
|
261
|
+
const panel = await loadPanel(resolve(cwd, panelPath));
|
|
262
|
+
const last = args.last === undefined ? 1 : Number(args.last);
|
|
263
|
+
if (args.last === true || !Number.isSafeInteger(last) || last < 1 || last > 30) {
|
|
264
|
+
err('citation report --last must be an integer from 1 to 30'); return 2;
|
|
265
|
+
}
|
|
266
|
+
const { panelEpochs } = await import('../src/citations/report.js');
|
|
267
|
+
const history = (await panelEpochs({ dir, panel })).slice(0, last);
|
|
268
|
+
if (!history.length) { err('no epochs found for this panel — run an epoch first'); return 2; }
|
|
269
|
+
const latestEpochId = history[0][0].epoch_id;
|
|
270
|
+
const html = await generateReport({ dir, epochId: latestEpochId, panel, history });
|
|
122
271
|
const outPath = args.out ? resolve(cwd, args.out) : join(dir, 'citations', `report-${latestEpochId.slice(0, 15)}.html`);
|
|
123
272
|
const { dirname, join: j } = await import('node:path');
|
|
124
273
|
const { mkdir } = await import('node:fs/promises');
|
|
@@ -127,7 +276,7 @@ export async function citationMain(argv = [], { cwd = process.cwd(), out = conso
|
|
|
127
276
|
out(`report written: ${outPath} (${Math.round(html.length / 1024)} KB, self-contained)`);
|
|
128
277
|
return 0;
|
|
129
278
|
}
|
|
130
|
-
if (
|
|
279
|
+
if (!['run', 'report', 'watch', 'alert', 'login', 'logout', 'sessions', 'sync'].includes(command)) { err(`unknown citation command: ${command}`); err(USAGE); return 2; }
|
|
131
280
|
|
|
132
281
|
const panelPath = args._[1];
|
|
133
282
|
if (!panelPath) { err('citation run needs a panel file'); return 2; }
|
|
@@ -170,14 +319,14 @@ export async function citationMain(argv = [], { cwd = process.cwd(), out = conso
|
|
|
170
319
|
if (spec.engine === 'mock') {
|
|
171
320
|
engines.set(identity, createMockEngine({ fixtures: panel.mockFixtures ?? {} }));
|
|
172
321
|
} else if (spec.engine === 'google-aio') {
|
|
173
|
-
//
|
|
174
|
-
// Activates live the moment DATAFORSEO_LOGIN + DATAFORSEO_PASSWORD exist.
|
|
322
|
+
// Live observations require explicit provider configuration; offline runs use mock.
|
|
175
323
|
const { createGoogleAioEngine } = await import('../src/citations/adapter.js');
|
|
176
324
|
const creds = env.DATAFORSEO_LOGIN && env.DATAFORSEO_PASSWORD
|
|
177
325
|
? { login: env.DATAFORSEO_LOGIN, password: env.DATAFORSEO_PASSWORD }
|
|
178
326
|
: null;
|
|
179
|
-
|
|
180
|
-
|
|
327
|
+
if (!creds) { err('google-aio requires DATAFORSEO_LOGIN and DATAFORSEO_PASSWORD; select mock for offline runs'); return 2; }
|
|
328
|
+
const context = panelLocaleContext(panel, spec);
|
|
329
|
+
engines.set(identity, createGoogleAioEngine({ credentials: creds, country: context.country, languageCode: context.locale.split('-')[0] }));
|
|
181
330
|
} else if (spec.engine === 'duck') {
|
|
182
331
|
// Self-hosted Duck2api on the VPS: the whole Claude family plus GPT-5.6
|
|
183
332
|
// anonymously through duck.ai. Localhost endpoint, no key.
|
package/cli/client.js
CHANGED
|
@@ -2,7 +2,7 @@ import {restResponseContract} from '../shared/response-contract.js';
|
|
|
2
2
|
// A small HTTP client for the cloud. Scoped keys only; no human session ever reaches here.
|
|
3
3
|
// DP-0036-T13: the CLI names itself so the usage counters can tell it from other REST callers.
|
|
4
4
|
// The version follows the plugin manifests; bump it with them.
|
|
5
|
-
export const CLI_VERSION = '0.
|
|
5
|
+
export const CLI_VERSION = '0.6.3';
|
|
6
6
|
export const CLI_USER_AGENT = `agentlinkops-cli/${CLI_VERSION}`;
|
|
7
7
|
export class CloudError extends Error {
|
|
8
8
|
constructor(code, status, details = null) {
|
package/cli/commands.js
CHANGED
|
@@ -33,13 +33,13 @@ export const resolveName = (commands, name) => commands.find(c => c.name === nam
|
|
|
33
33
|
// The index: grouped by toolset, one line per command, core and write and admission marked.
|
|
34
34
|
export function renderIndex(commands, { toolset = null, json = false } = {}) {
|
|
35
35
|
const selected = toolset ? commands.filter(c => c.toolset === toolset) : commands;
|
|
36
|
-
if (json) return JSON.stringify({ toolsets: toolset ? { [toolset]: CATALOG_TOOLSETS[toolset] } : CATALOG_TOOLSETS, commands: selected.map(c => ({ name: c.name, toolset: c.toolset, tier: c.tier, readOnly: c.readOnly, summary: oneLiner(c.description,
|
|
36
|
+
if (json) return JSON.stringify({ toolsets: toolset ? { [toolset]: CATALOG_TOOLSETS[toolset] } : CATALOG_TOOLSETS, commands: selected.map(c => ({ name: c.name, toolset: c.toolset, tier: c.tier, readOnly: c.readOnly, summary: oneLiner(c.description, 64), ...(c.admissionGated ? { admission: 'gated' } : {}), ...(c.aliases?.length ? { aliases: c.aliases } : {}) })), next: 'Run `agentlinkops describe NAME` for a schema, then `agentlinkops call NAME --args JSON`.' });
|
|
37
37
|
const groups = new Map();
|
|
38
38
|
for (const c of selected) (groups.get(c.toolset) ?? groups.set(c.toolset, []).get(c.toolset)).push(c);
|
|
39
39
|
const lines = [];
|
|
40
40
|
for (const [group, members] of [...groups].sort()) {
|
|
41
|
-
lines.push(CATALOG_TOOLSETS[group] ? `${group}: ${CATALOG_TOOLSETS[group]}` : `${group}:`);
|
|
42
|
-
for (const c of members.sort((a, b) => a.name.localeCompare(b.name))) lines.push(` ${c.name}${c.tier === 'core' ? ' (core)' : ''}${c.readOnly ? '' : ' [write]'}${c.admissionGated ? ' [admission-gated]' : ''} ${oneLiner(c.description,
|
|
41
|
+
lines.push(CATALOG_TOOLSETS[group] ? `${group}: ${oneLiner(CATALOG_TOOLSETS[group], 44)}` : `${group}:`);
|
|
42
|
+
for (const c of members.sort((a, b) => a.name.localeCompare(b.name))) lines.push(` ${c.name}${c.tier === 'core' ? ' (core)' : ''}${c.readOnly ? '' : ' [write]'}${c.admissionGated ? ' [admission-gated]' : ''} ${oneLiner(c.description, 22)}`);
|
|
43
43
|
lines.push('');
|
|
44
44
|
}
|
|
45
45
|
lines.push(`${selected.length} commands${toolset ? ` in ${toolset}` : ''}. describe NAME shows the schema; call NAME --args JSON runs it.`);
|
package/cli/config.js
CHANGED
|
@@ -19,6 +19,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
19
19
|
ledger: 'links.jsonl',
|
|
20
20
|
receipts: 'receipts.jsonl',
|
|
21
21
|
observations: 'observations.jsonl',
|
|
22
|
+
indexObservations: 'index-observations.jsonl',
|
|
22
23
|
events: 'events.jsonl',
|
|
23
24
|
candidates: 'candidates.jsonl',
|
|
24
25
|
state: 'state.json',
|
|
@@ -98,7 +99,7 @@ export async function loadConfig({ cwd = process.cwd(), root = null } = {}) {
|
|
|
98
99
|
defaults: { ...DEFAULTS, ...file.defaults },
|
|
99
100
|
paths: {
|
|
100
101
|
ledger: at('ledger'), observations: at('observations'), events: at('events'),
|
|
101
|
-
candidates: at('candidates'), state: at('state'), receipts: at('receipts'),
|
|
102
|
+
candidates: at('candidates'), state: at('state'), receipts: at('receipts'), indexObservations: at('indexObservations'),
|
|
102
103
|
},
|
|
103
104
|
};
|
|
104
105
|
}
|
package/cli/connection.js
CHANGED
|
File without changes
|
package/cli/context-mcp.js
CHANGED
|
File without changes
|
package/cli/context.js
CHANGED
|
File without changes
|
package/cli/delivery.js
CHANGED
|
File without changes
|
package/cli/disavow.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {open,unlink} from 'node:fs/promises';
|
|
2
|
+
import {resolve} from 'node:path';
|
|
3
|
+
import {createHash} from 'node:crypto';
|
|
4
|
+
import {loadConfig,ConfigError} from './config.js';
|
|
5
|
+
import {cloudConnection} from './connection.js';
|
|
6
|
+
import {createClient,CloudError} from './client.js';
|
|
7
|
+
import * as C from '../shared/disavow-contract.js';
|
|
8
|
+
|
|
9
|
+
export const DISAVOW_USAGE='disavow list|history|propose --kind domain|url --value VALUE|import FILE|export [--out FILE]|approve|reject|delete --rule-id ID --revision N [--project-id ID]';
|
|
10
|
+
const bad=message=>{throw new ConfigError(message);};
|
|
11
|
+
export async function disavowMain(args,{cwd=process.cwd(),env=process.env,fetchImpl=globalThis.fetch,out=console.log}={}){
|
|
12
|
+
const action=args._[1];if(!action||action==='help'){out(DISAVOW_USAGE);return 0;}
|
|
13
|
+
const schemas={list:C.disavowListInput,history:C.disavowHistoryInput,propose:C.disavowProposeInput,import:C.disavowImportInput,export:C.disavowProjectInput,approve:C.disavowReviewInput,reject:C.disavowReviewInput,delete:C.disavowDeleteInput};
|
|
14
|
+
const names={list:'list_disavow_rules',history:'list_disavow_exports',propose:'propose_disavow_rule',import:'import_disavow_rules',export:'export_disavow_rules',approve:'review_disavow_rule',reject:'review_disavow_rule',delete:'delete_disavow_rule'};
|
|
15
|
+
if(!schemas[action])bad(DISAVOW_USAGE);
|
|
16
|
+
const options={list:['limit','cursor','status'],history:['limit','cursor'],propose:['kind','value','comment'],import:[],export:['out'],approve:['rule-id','revision'],reject:['rule-id','revision'],delete:['rule-id','revision']}[action];
|
|
17
|
+
if(Object.keys(args).some(key=>!['_','tag','json','project-id',...options].includes(key))||args.tag?.length||args._.length!==(action==='import'?3:2))bad(DISAVOW_USAGE);
|
|
18
|
+
const config=await loadConfig({cwd}),connection=cloudConnection(config,env),projectId=args['project-id']??connection.projectId;
|
|
19
|
+
let input={projectId};
|
|
20
|
+
if(action==='list'||action==='history')input={...input,...(args.limit===undefined?{}:{limit:Number(args.limit)}),...(args.cursor===undefined?{}:{cursor:args.cursor}),...(args.status===undefined?{}:{status:args.status})};
|
|
21
|
+
if(action==='propose')input={...input,kind:args.kind,value:args.value,source:'agent_proposal',status:'proposed',...(args.comment===undefined?{}:{comments:[`# ${args.comment}`]})};
|
|
22
|
+
if(['approve','reject','delete'].includes(action))input={...input,ruleId:args['rule-id'],revision:Number(args.revision),...(action==='delete'?{}:{decision:action})};
|
|
23
|
+
if(action==='import'){
|
|
24
|
+
const file=await open(resolve(cwd,args._[2]),'r');try{if((await file.stat()).size>C.DISAVOW_LIMITS.importBytes)bad('Disavow import exceeds 160 KiB.');input.text=await file.readFile('utf8');}finally{await file.close();}
|
|
25
|
+
}
|
|
26
|
+
const parsed=schemas[action].safeParse(input);if(!parsed.success)bad(`Invalid ${action} arguments. ${DISAVOW_USAGE}`);
|
|
27
|
+
const client=createClient({...connection,fetchImpl});
|
|
28
|
+
// Reserve both local artifacts before creating a remote export receipt. Existing
|
|
29
|
+
// customer files are never replaced and a path error cannot consume an export.
|
|
30
|
+
let target,receiptFile,targetPath,receiptPath,received=false;
|
|
31
|
+
if(action==='export'&&args.out!==undefined){
|
|
32
|
+
if(typeof args.out!=='string'||!args.out)bad('--out requires a file path.');
|
|
33
|
+
targetPath=resolve(cwd,args.out);receiptPath=`${targetPath}.receipt.json`;
|
|
34
|
+
target=await open(targetPath,'wx');try{receiptFile=await open(receiptPath,'wx');}catch(error){await target.close();await unlink(targetPath);throw error;}
|
|
35
|
+
}
|
|
36
|
+
try{
|
|
37
|
+
const result=await client.callCommand(names[action],parsed.data);
|
|
38
|
+
if(action==='export'){
|
|
39
|
+
const checked=C.disavowExportOutput.parse(result);
|
|
40
|
+
if(createHash('sha256').update(checked.text).digest('hex')!==checked.receipt.sha256||Buffer.byteLength(checked.text)!==checked.receipt.byte_length)throw new CloudError('INVALID_RESPONSE',502);
|
|
41
|
+
received=true;
|
|
42
|
+
if(target){await target.writeFile(checked.text,'utf8');await receiptFile.writeFile(JSON.stringify(checked.receipt,null,2)+'\n','utf8');out(JSON.stringify({file:targetPath,receiptFile:receiptPath,receipt:checked.receipt}));}
|
|
43
|
+
else out(JSON.stringify(checked));
|
|
44
|
+
}else out(JSON.stringify(result));
|
|
45
|
+
return 0;
|
|
46
|
+
}catch(error){
|
|
47
|
+
if(error.code==='HUMAN_ADMIN_REQUIRED')throw new ConfigError('Approve, reject or delete this rule in Disavow using the signed-in owner/admin console. Agent credentials cannot approve rules.');
|
|
48
|
+
if(received)out(JSON.stringify({error:{code:'DISAVOW_ARTIFACT_WRITE_FAILED',message:'The hosted export receipt exists. Inspect export history before retrying; local output may be incomplete.'}}));
|
|
49
|
+
throw error;
|
|
50
|
+
}finally{
|
|
51
|
+
await target?.close();await receiptFile?.close();
|
|
52
|
+
if(target&&!received){await unlink(targetPath).catch(()=>{});await unlink(receiptPath).catch(()=>{});}
|
|
53
|
+
}
|
|
54
|
+
}
|
package/cli/doctor.js
CHANGED
|
@@ -252,14 +252,13 @@ export async function doctorMain(argv = [], { cwd = process.cwd(), out = console
|
|
|
252
252
|
checks.push({ status: 'skip', name: 'token', detail: `set in ${source}; not verified (cloud unreachable)` });
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
255
|
+
// Doctor does not contact citation providers or validate saved browser sessions.
|
|
256
|
+
// Environment presence must not imply an engine can run successfully.
|
|
257
|
+
const aioCredentials = Boolean(env.DATAFORSEO_LOGIN && env.DATAFORSEO_PASSWORD);
|
|
258
|
+
checks.push({
|
|
259
|
+
status: 'skip', name: 'citations',
|
|
260
|
+
detail: `${aioCredentials ? 'google-aio credentials are set but unverified; ' : ''}live engines were not checked; configure the selected engine credentials or browser session. Mock runs work offline`,
|
|
261
|
+
});
|
|
263
262
|
|
|
264
263
|
// Browser engines: the Camoufox stack. Each missing piece names its fix; none
|
|
265
264
|
// of it fails doctor, because API and mock engines do not need it.
|
package/cli/env.js
CHANGED
|
File without changes
|
package/cli/fleet.js
CHANGED
|
File without changes
|
package/cli/gsc-browser.js
CHANGED
|
File without changes
|
package/cli/import.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {readFile,writeFile,open,rename,unlink,mkdir,stat} from 'node:fs/promises';
|
|
2
|
+
import {dirname,resolve} from 'node:path';
|
|
3
|
+
import {createHash} from 'node:crypto';
|
|
4
|
+
import {loadConfig,ConfigError} from './config.js';
|
|
5
|
+
import {cloudConnection} from './connection.js';
|
|
6
|
+
import {createClient} from './client.js';
|
|
7
|
+
import {parseCsv} from './adapters/csv.js';
|
|
8
|
+
import {indexEvidenceReference,describeEvidenceReference} from '../src/evidence-reference.js';
|
|
9
|
+
import {indexReceiptResponse} from '../shared/index-observation-contract.js';
|
|
10
|
+
const sha=value=>createHash('sha256').update(value).digest('hex');
|
|
11
|
+
const fail=message=>{throw new ConfigError(message);};
|
|
12
|
+
export const INDEX_CLI_HELP='agentlinkops index pull --watch-id ID [--limit 50] [--before ID] | import RECEIPTS.json | export [--out FILE] | csv URLS.csv [--out FILE] | check-csv URLS.csv --connection-id ID --request-id ID --out FILE';
|
|
13
|
+
function validate(row){
|
|
14
|
+
if(row?.v!==1||! /^[\w-]{1,128}$/.test(row.project_id??'')||! /^[\w-]{1,128}$/.test(row.watch_id??''))fail('Invalid local index receipt scope');
|
|
15
|
+
const receipt=indexReceiptResponse.parse(row.receipt);
|
|
16
|
+
if(receipt.observation.checked_at!==receipt.observation.checkedAt||receipt.observation.observation_id!==receipt.observation.observationId||!Number.isFinite(Date.parse(receipt.observation.checkedAt)))fail('Index receipt observation identity mismatch');
|
|
17
|
+
if(receipt.evidenceAvailable&&receipt.rawResponse==null)fail('Available index evidence requires an actual raw response');
|
|
18
|
+
if(!receipt.evidenceAvailable&&receipt.rawResponse!==null)fail('Unavailable index evidence must not contain a raw body');
|
|
19
|
+
if(receipt.evidenceAvailable&&sha(JSON.stringify(receipt.rawResponse))!==receipt.observation.evidenceSha256)fail('Index receipt payload hash mismatch');
|
|
20
|
+
return {...row,receipt};
|
|
21
|
+
}
|
|
22
|
+
async function atomicOutput(path,text){await mkdir(dirname(path),{recursive:true});const temporary=`${path}.${crypto.randomUUID()}.tmp`;try{await writeFile(temporary,text,{flag:'wx'});await rename(temporary,path);}finally{await unlink(temporary).catch(()=>{});}}
|
|
23
|
+
async function boundedText(path){const info=await stat(path);if(info.size>32*1024*1024)fail('Index input exceeds 32 MiB; split the export');return readFile(path,'utf8');}
|
|
24
|
+
export async function readIndexReceipts(path){
|
|
25
|
+
let text;try{text=await boundedText(path);}catch(error){if(error.code==='ENOENT')return [];throw error;}
|
|
26
|
+
return text.split('\n').filter(line=>line.trim()).map(line=>validate(JSON.parse(line)));
|
|
27
|
+
}
|
|
28
|
+
export async function saveIndexReceipts(path,rows){
|
|
29
|
+
const checked=rows.map(validate);await mkdir(dirname(path),{recursive:true});let lock;
|
|
30
|
+
try{lock=await open(`${path}.lock`,'wx');}catch(error){if(error.code==='EEXIST')fail('Index receipt writer already locked');throw error;}
|
|
31
|
+
const temporary=`${path}.${crypto.randomUUID()}.tmp`;
|
|
32
|
+
try{
|
|
33
|
+
const existing=await readIndexReceipts(path),byId=new Map(existing.map(r=>[r.receipt.observation.id,r]));
|
|
34
|
+
for(const row of checked){const id=row.receipt.observation.id,previous=byId.get(id);if(previous){
|
|
35
|
+
if(previous.project_id!==row.project_id||previous.watch_id!==row.watch_id||JSON.stringify(previous.receipt.observation)!==JSON.stringify(row.receipt.observation))fail('Index receipt identity conflict');
|
|
36
|
+
if(previous.receipt.evidenceAvailable&&!row.receipt.evidenceAvailable)continue;
|
|
37
|
+
}byId.set(id,row);}
|
|
38
|
+
const text=[...byId.values()].sort((a,b)=>a.receipt.observation.id.localeCompare(b.receipt.observation.id)).map(r=>JSON.stringify(r)).join('\n')+'\n';if(Buffer.byteLength(text)>32*1024*1024)fail('Local index receipts exceed 32 MiB; archive before importing more');
|
|
39
|
+
await writeFile(temporary,text,{flag:'wx'});await rename(temporary,path);return {saved:checked.length,total:byId.size};
|
|
40
|
+
}finally{await unlink(temporary).catch(()=>{});await lock.close();await unlink(`${path}.lock`);}
|
|
41
|
+
}
|
|
42
|
+
export function locateIndexReceipt(rows,receiptId){
|
|
43
|
+
const row=rows.find(r=>r.receipt.observation.id===receiptId);if(!row)fail('No local index receipt matches this reference');
|
|
44
|
+
const reference=indexEvidenceReference({receiptId});
|
|
45
|
+
return {...row.receipt,projectId:row.project_id,watchId:row.watch_id,evidence_reference:reference,reference_resolution:describeEvidenceReference(reference),notes:['Retained index evidence describes the provider snapshot at its check date, not a live crawl.','This explicit local export survives hosted cancellation; hosted raw retention does not remove customer-owned copies.']};
|
|
46
|
+
}
|
|
47
|
+
export function indexRowsForUrl(rows,url){
|
|
48
|
+
const latest=new Map();for(const row of rows){const o=row.receipt.observation;if(o.url!==url)continue;const previous=latest.get(o.source_key);if(!previous||o.checked_at>previous.checked_at||(o.checked_at===previous.checked_at&&o.id>previous.id))latest.set(o.source_key,{...o,locator:indexEvidenceReference({receiptId:o.id}),hosted_locator:indexEvidenceReference({receiptId:o.id,projectId:row.project_id,watchId:row.watch_id,storage:'hosted'})});}
|
|
49
|
+
return [...latest.values()].sort((a,b)=>a.source_key.localeCompare(b.source_key));
|
|
50
|
+
}
|
|
51
|
+
const cell=value=>{const raw=String(value??''),safe=/^[\s]*[=+\-@]|^[\t\r]/.test(raw)?`'${raw}`:raw;return `"${safe.replaceAll('"','""')}"`;};
|
|
52
|
+
export function indexCsv(urls,receipts){
|
|
53
|
+
const output=[['URL','Verdict','Reason','Check Date','Confidence','Backend','Evidence Reference']];
|
|
54
|
+
for(const url of urls){const rows=indexRowsForUrl(receipts,url);if(!rows.length)output.push([url,'unknown','not_checked','','unknown','','']);else for(const row of rows)output.push([url,row.tier,row.reason,row.checked_at,row.confidence,row.backend,row.locator]);}
|
|
55
|
+
return output.map(row=>row.map(cell).join(',')).join('\r\n')+'\r\n';
|
|
56
|
+
}
|
|
57
|
+
function csvInput(text){
|
|
58
|
+
const [header,...rows]=parseCsv(text),urlAt=header?.findIndex(v=>['url','source_url'].includes(v.trim().toLowerCase())),watchAt=header?.findIndex(v=>v.trim().toLowerCase()==='watch_id');
|
|
59
|
+
if(urlAt==null||urlAt<0||!rows.length||rows.length>100)fail('Index CSV requires URL or source_url and 1–100 rows');
|
|
60
|
+
return rows.map(row=>{let url;try{url=new URL(row[urlAt]);}catch{fail('Invalid index CSV URL');}if(!['https:','http:'].includes(url.protocol)||url.username||url.password||url.hash)fail('Invalid index CSV URL');return {url:url.href,watchId:watchAt>=0?row[watchAt]:null};});
|
|
61
|
+
}
|
|
62
|
+
export async function indexMain(args,{cwd=process.cwd(),env=process.env,out=console.log,fetchImpl=globalThis.fetch}={}){
|
|
63
|
+
const command=args._[1],config=await loadConfig({cwd}),path=config.paths.indexObservations;
|
|
64
|
+
const allowed=new Set(['_','tag','out','watch-id','limit','before','connection-id','request-id']);if(args.tag?.length||Object.keys(args).some(key=>!allowed.has(key)))fail(INDEX_CLI_HELP);
|
|
65
|
+
if(command==='import'){
|
|
66
|
+
if(!args._[2])fail(INDEX_CLI_HELP);const parsed=JSON.parse(await boundedText(resolve(cwd,args._[2])));if(!Array.isArray(parsed)||parsed.length>100)fail('Import requires an array of at most 100 scoped index receipts');out(JSON.stringify(await saveIndexReceipts(path,parsed)));return 0;
|
|
67
|
+
}
|
|
68
|
+
if(command==='export'){const text=JSON.stringify(await readIndexReceipts(path),null,2);if(args.out){if(typeof args.out!=='string')fail('--out requires a file');await atomicOutput(resolve(cwd,args.out),text);}else out(text);return 0;}
|
|
69
|
+
if(command==='csv'||command==='check-csv'){
|
|
70
|
+
if(!args._[2])fail(INDEX_CLI_HELP);const input=csvInput(await boundedText(resolve(cwd,args._[2])));
|
|
71
|
+
if(command==='check-csv'){
|
|
72
|
+
if(typeof args['connection-id']!=='string'||typeof args.out!=='string'||typeof args['request-id']!=='string'||! /^[\x21-\x7e]{1,128}$/.test(args['request-id'])||input.some(r=>!r.watchId))fail('check-csv requires watch_id for every row, --connection-id, --request-id and --out');
|
|
73
|
+
const connection=cloudConnection(config,env);if(!connection.projectId)fail('Connect this repository to a project first');const client=createClient({...connection,fetchImpl});
|
|
74
|
+
// Validate every supplied URL/watch pairing before the first inspection.
|
|
75
|
+
for(const row of input){const watch=await client.callCommand('get_link_watch',{watchId:row.watchId});if(watch.project_id!==connection.projectId||watch.source_url!==row.url)fail('CSV URL/watch scope mismatch; no inspection was started');}
|
|
76
|
+
const batchKey=sha(JSON.stringify([connection.projectId,args['connection-id'],args['request-id'],input]));
|
|
77
|
+
for(const row of input){const result=await client.callCommand('inspect_index_status',{connectionId:args['connection-id'],watchId:row.watchId,idempotencyKey:`csv-${sha(JSON.stringify([batchKey,row.watchId,row.url]))}`});await saveIndexReceipts(path,[{v:1,project_id:connection.projectId,watch_id:row.watchId,receipt:{observation:result.observation,rawResponse:result.rawResponse,evidenceAvailable:result.evidenceAvailable}}]);}
|
|
78
|
+
}
|
|
79
|
+
const csv=indexCsv(input.map(r=>r.url),await readIndexReceipts(path));if(args.out){if(typeof args.out!=='string')fail('--out requires a file');const target=resolve(cwd,args.out);await atomicOutput(target,csv);}else out(csv);return 0;
|
|
80
|
+
}
|
|
81
|
+
if(command==='pull'){
|
|
82
|
+
if(typeof args['watch-id']!=='string')fail(INDEX_CLI_HELP);const limit=Number(args.limit??50);if(!Number.isInteger(limit)||limit<1||limit>100)fail('Index pull limit must be 1–100');
|
|
83
|
+
const connection=cloudConnection(config,env);if(!connection.projectId)fail('Connect this repository to a project first');const client=createClient({...connection,fetchImpl});
|
|
84
|
+
const page=await client.callCommand('list_index_observations',{projectId:connection.projectId,watchId:args['watch-id'],limit,...(args.before?{beforeId:args.before}:{})});const rows=[];
|
|
85
|
+
for(const observation of page.observations)rows.push({v:1,project_id:connection.projectId,watch_id:args['watch-id'],receipt:await client.callCommand('get_index_observation',{projectId:connection.projectId,watchId:args['watch-id'],receiptId:observation.id})});
|
|
86
|
+
out(JSON.stringify({...await saveIndexReceipts(path,rows),nextCursor:page.nextCursor}));return 0;
|
|
87
|
+
}
|
|
88
|
+
fail(INDEX_CLI_HELP);
|
|
89
|
+
}
|
package/cli/ledger.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// number that is wrong in a way they discover months later.
|
|
7
7
|
import { readFile, writeFile, mkdir, rename, open, unlink } from 'node:fs/promises';
|
|
8
8
|
import { dirname, join } from 'node:path';
|
|
9
|
+
import {lifecycleDeal,emptyDeal} from '../shared/lifecycle-contract.js';
|
|
9
10
|
|
|
10
11
|
export const INTENTS = Object.freeze(['wanted', 'expected', 'retired']);
|
|
11
12
|
export const SCOPES = Object.freeze(['exact', 'domain', 'subdomain', 'path']);
|
|
@@ -17,7 +18,7 @@ export const DEFAULT_CADENCE = Object.freeze({ wanted: 'weekly', expected: 'dail
|
|
|
17
18
|
|
|
18
19
|
// Stable key order, so `fmt` produces a diff that names the entry that changed rather than
|
|
19
20
|
// reordering every line.
|
|
20
|
-
const KEY_ORDER = ['id', 'intent', 'source', 'target', 'scope', 'expect', 'cadence', 'ref', 'origin', 'tags', 'added', 'note'];
|
|
21
|
+
const KEY_ORDER = ['id', 'intent', 'source', 'target', 'scope', 'expect', 'cadence', 'ref', 'origin', 'tags', 'added', 'note', 'deal'];
|
|
21
22
|
|
|
22
23
|
// Where an entry came from, when it was not typed by hand. `placement_run` is adopt-side
|
|
23
24
|
// lineage, not import-side: a run's queue holds decisions already made and its output reports
|
|
@@ -115,6 +116,11 @@ export function normalizeEntry(input, { assignId = false } = {}) {
|
|
|
115
116
|
else entry[field] = input[field];
|
|
116
117
|
}
|
|
117
118
|
}
|
|
119
|
+
if ('deal' in input) {
|
|
120
|
+
const parsed=lifecycleDeal.safeParse(input.deal===null?emptyDeal():input.deal);
|
|
121
|
+
if(!parsed.success)problems.push('deal must contain valid lifecycle fields with paired cost/currency');
|
|
122
|
+
else entry.deal=parsed.data;
|
|
123
|
+
}
|
|
118
124
|
// An unknown key is kept rather than discarded: it is the customer's file, and a future
|
|
119
125
|
// version of this tool may well mean something by it.
|
|
120
126
|
for (const [key, value] of Object.entries(input)) if (!KEY_ORDER.includes(key)) entry[key] = value;
|