agentlinkops 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +24 -0
- package/CHANGELOG.md +52 -0
- package/LICENSE +190 -0
- package/NOTICE +17 -0
- package/README.md +202 -0
- package/bin/agentlinkops.mjs +4 -0
- package/bin/linktrail.mjs +6 -0
- package/cli/adapters/csv.js +68 -0
- package/cli/adapters/sqlite-crm.js +79 -0
- package/cli/adapters/suppliers.js +180 -0
- package/cli/agent-setup.js +203 -0
- package/cli/agentlinkops.mjs +4 -0
- package/cli/args.js +18 -0
- package/cli/catalog.js +7483 -0
- package/cli/check.js +119 -0
- package/cli/citation.js +109 -0
- package/cli/client.js +60 -0
- package/cli/commands.js +183 -0
- package/cli/config.js +104 -0
- package/cli/connection.js +90 -0
- package/cli/context-mcp.js +193 -0
- package/cli/context.js +340 -0
- package/cli/delivery.js +67 -0
- package/cli/doctor.js +283 -0
- package/cli/env.js +66 -0
- package/cli/fleet.js +82 -0
- package/cli/gsc-browser.js +239 -0
- package/cli/import.js +108 -0
- package/cli/ledger.js +232 -0
- package/cli/linktrail.mjs +6 -0
- package/cli/local-result.js +161 -0
- package/cli/main.js +523 -0
- package/cli/mirror.js +126 -0
- package/cli/mix.js +123 -0
- package/cli/platforms.js +161 -0
- package/cli/receipt-performance.js +64 -0
- package/cli/receipts.js +122 -0
- package/cli/report.js +158 -0
- package/cli/setup-plan.js +211 -0
- package/cli/skill.js +39 -0
- package/cli/state.js +106 -0
- package/cli/status.js +51 -0
- package/cli/sync.js +179 -0
- package/package.json +59 -0
- package/references/cli.md +198 -0
- package/references/disavow.md +73 -0
- package/references/reports.md +53 -0
- package/scripts/agentlinkops.py +651 -0
- package/scripts/disavow.py +207 -0
- package/scripts/linktrail.py +10 -0
- package/scripts/migrations/001_crm.sql +45 -0
- package/scripts/migrations/002_cloud_events.sql +13 -0
- package/scripts/migrations/003_disavow.sql +20 -0
- package/scripts/migrations/004_placement_costs.sql +2 -0
- package/scripts/report.py +113 -0
- package/shared/agent-clients.js +49 -0
- package/shared/observation-event.js +43 -0
- package/shared/response-contract.js +59 -0
- package/shared/search-connection-contract.js +18 -0
- package/skills/agentlinkops-assets/SKILL.md +13 -0
- package/skills/agentlinkops-assets/agents/openai.yaml +5 -0
- package/skills/agentlinkops-campaigns/SKILL.md +17 -0
- package/skills/agentlinkops-campaigns/agents/openai.yaml +12 -0
- package/skills/agentlinkops-connect/SKILL.md +33 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +12 -0
- package/skills/agentlinkops-connect/references/agentlinkops.md +266 -0
- package/skills/agentlinkops-crm/SKILL.md +27 -0
- package/skills/agentlinkops-crm/agents/openai.yaml +12 -0
- package/skills/content-defingerprinting/SKILL.md +29 -0
- package/skills/content-defingerprinting/scripts/watermarks/README.md +102 -0
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +26 -0
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +52 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +21 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +96 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +432 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +86 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +460 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +651 -0
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +27 -0
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +212 -0
- package/skills/internal-linking-optimizer/SKILL.md +56 -0
- package/skills/internal-linking-optimizer/references/source-prompt.md +76 -0
- package/skills/pre-publish-review/SKILL.md +179 -0
- package/skills/source-cited-content-builder/SKILL.md +56 -0
- package/skills/source-cited-content-builder/references/source-prompt.md +19 -0
- package/skills/source-cited-humanizer/SKILL.md +20 -0
- package/src/citations/adapter.js +141 -0
- package/src/citations/contract.js +171 -0
- package/src/citations/match.js +120 -0
- package/src/citations/runner.js +151 -0
- package/src/citations/stats.js +74 -0
- package/src/competitors/contract.js +157 -0
- package/src/competitors/domain-mix.js +199 -0
- package/src/context/ga4.js +652 -0
- package/src/context/gsc.js +925 -0
- package/src/context/site-profile.js +445 -0
- package/src/context/util.js +71 -0
- package/src/context-tool-registry.js +27 -0
- package/src/corpus/acquire.js +135 -0
- package/src/corpus/seeds.js +182 -0
- package/src/discovery/contract.js +237 -0
- package/src/discovery/import.js +156 -0
- package/src/tool-views.js +117 -0
- package/src/verifier/fetch.js +303 -0
- package/src/verifier/index.js +311 -0
- package/src/verifier/observation-kind.js +91 -0
- package/src/verifier/parser.js +241 -0
- package/src/verifier/readiness.js +90 -0
- package/src/verifier/render-evidence.js +57 -0
- package/src/verifier/robots.js +85 -0
- package/src/verifier/transition.js +129 -0
- package/src/verifier/url.js +56 -0
- package/templates/SITE.template.md +59 -0
- package/templates/campaigns.v1.json +65 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { lstat } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join, parse, resolve, sep } from 'node:path';
|
|
3
|
+
import { parseArgs } from './args.js';
|
|
4
|
+
import { ConfigError } from './config.js';
|
|
5
|
+
|
|
6
|
+
const GOALS = new Set(['verify-links', 'prepare-campaign', 'build-content']);
|
|
7
|
+
const MODES = new Set(['local', 'hosted', 'external']);
|
|
8
|
+
const USAGE = 'setup --plan --goal verify-links|prepare-campaign|build-content [--mode local|hosted|external]';
|
|
9
|
+
const PATHS = [
|
|
10
|
+
['.git', ['file', 'directory']],
|
|
11
|
+
['.agentlinkops/config.json', ['file']],
|
|
12
|
+
['.agentlinkops/links.jsonl', ['file']],
|
|
13
|
+
// The legacy ledger directory is still read transparently (DP-0029-T03), so its presence
|
|
14
|
+
// is planning evidence too until the cutover; `agentlinkops migrate` renames it.
|
|
15
|
+
['.linktrail/config.json', ['file']],
|
|
16
|
+
['.linktrail/links.jsonl', ['file']],
|
|
17
|
+
['.claude/SITE.md', ['file']],
|
|
18
|
+
['.agents/SITE.md', ['file']],
|
|
19
|
+
['SITE.md', ['file']],
|
|
20
|
+
['operations/seo/backlinks/registry.csv', ['file']],
|
|
21
|
+
['agentlinkops.sqlite', ['file']],
|
|
22
|
+
['linktrail.sqlite', ['file']],
|
|
23
|
+
['.agents/plugins/agentlinkops', ['directory']],
|
|
24
|
+
];
|
|
25
|
+
const UNSAFE = new Set(['symlink', 'blocked', 'unreadable']);
|
|
26
|
+
|
|
27
|
+
async function metadata(path) {
|
|
28
|
+
try {
|
|
29
|
+
const stat = await lstat(path);
|
|
30
|
+
if (stat.isSymbolicLink()) return 'symlink';
|
|
31
|
+
if (stat.isDirectory()) return 'directory';
|
|
32
|
+
if (stat.isFile()) return 'file';
|
|
33
|
+
return 'blocked';
|
|
34
|
+
} catch (error) {
|
|
35
|
+
// A missing component is the only error that establishes absence. Never expose
|
|
36
|
+
// OS error messages, which can contain absolute paths or other customer data.
|
|
37
|
+
return error.code === 'ENOENT' ? 'missing' : 'unreadable';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function workspaceMetadata(cwd) {
|
|
42
|
+
// Preserve lexical components until inspected: resolving alias/../workspace
|
|
43
|
+
// first would hide an unsafe symlink ancestor from this inventory.
|
|
44
|
+
const absolute = isAbsolute(cwd) ? cwd : `${process.cwd()}${sep}${cwd}`;
|
|
45
|
+
const root = parse(absolute).root;
|
|
46
|
+
let current = root;
|
|
47
|
+
const components = absolute.slice(root.length).split(sep).filter(Boolean);
|
|
48
|
+
for (const component of [null, ...components]) {
|
|
49
|
+
if (component !== null) current = join(current, component);
|
|
50
|
+
const status = await metadata(current);
|
|
51
|
+
if (status !== 'directory') return { root: absolute, status: status === 'file' ? 'blocked' : status };
|
|
52
|
+
}
|
|
53
|
+
return { root: resolve(absolute), status: 'directory' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function inspectPath(root, relative) {
|
|
57
|
+
const components = relative.split('/');
|
|
58
|
+
let current = root;
|
|
59
|
+
for (let index = 0; index < components.length; index++) {
|
|
60
|
+
current = join(current, components[index]);
|
|
61
|
+
const status = await metadata(current);
|
|
62
|
+
if (index === components.length - 1) return status;
|
|
63
|
+
if (status !== 'directory') return status === 'file' ? 'blocked' : status;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function inspectWorkspace(cwd) {
|
|
68
|
+
const workspace = await workspaceMetadata(cwd);
|
|
69
|
+
const paths = [];
|
|
70
|
+
// Sequential component inspection prevents following any observed symlink. This
|
|
71
|
+
// is a metadata snapshot, not a guarantee against concurrent filesystem changes.
|
|
72
|
+
for (const [path] of PATHS) {
|
|
73
|
+
paths.push({ path, status: workspace.status === 'directory'
|
|
74
|
+
? await inspectPath(workspace.root, path)
|
|
75
|
+
: workspace.status === 'missing' ? 'missing' : 'blocked' });
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
status: 'known_path_metadata',
|
|
79
|
+
workspace: { status: workspace.status },
|
|
80
|
+
paths,
|
|
81
|
+
coverage: {
|
|
82
|
+
contentsRead: false,
|
|
83
|
+
recursiveDiscovery: false,
|
|
84
|
+
customPathsResolved: false,
|
|
85
|
+
note: 'Only known standard paths were inspected. Config contents, custom ledger paths, record validity, installation health and connections were not checked.',
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const step = (id, execution, action, requires, expectedEvidence) => ({ id, execution, action, requires, expectedEvidence });
|
|
91
|
+
|
|
92
|
+
function taskSteps(goal, mode, inspection, unsafe) {
|
|
93
|
+
const paths = new Map((inspection.paths ?? []).map(entry => [entry.path, entry.status]));
|
|
94
|
+
const has = path => paths.get(path) === 'file';
|
|
95
|
+
const steps = [];
|
|
96
|
+
if (unsafe) steps.push(step('resolve-workspace-findings', 'customer_local',
|
|
97
|
+
'Resolve the reported workspace findings before selecting files for inspection or writing there.',
|
|
98
|
+
['Customer-selected safe workspace'], ['Known paths can be inspected without symlink traversal or unreadable components']));
|
|
99
|
+
|
|
100
|
+
if (mode === 'hosted') {
|
|
101
|
+
steps.push(step('check-hosted-access', 'customer_agent',
|
|
102
|
+
'Inspect the client’s available AgentLinkOps connection and actual permissions. Complete normal account connection only if the selected hosted task needs it.',
|
|
103
|
+
['Customer-selected hosted task'], ['Actual available tools, granted scopes and task availability, or an explicit unavailable state']));
|
|
104
|
+
steps.push(step('prepare-supplied-inputs', 'customer_agent',
|
|
105
|
+
'Prepare the selected task from supplied inputs even when the connection, permission or hosted capability is unavailable; retain the next action.',
|
|
106
|
+
['Customer-supplied task inputs'], ['A useful input brief and resumable missing-access or unsupported-capability checkpoint']));
|
|
107
|
+
} else if (mode === 'external') {
|
|
108
|
+
steps.push(step('map-external-records', 'customer_agent',
|
|
109
|
+
'Map customer-selected records to the chosen CRM or sender while preserving it as the system of record. Do not initialize a replacement CRM.',
|
|
110
|
+
['Selected CRM or sender', 'Customer-selected records'], ['Reviewed field mapping, state ownership and existing external IDs']));
|
|
111
|
+
steps.push(step('verify-external-connection', 'customer_external',
|
|
112
|
+
'Verify the connection through an authorized scoped operation and readback. Record unsupported, missing-auth or missing-scope states without retry loops.',
|
|
113
|
+
['Available external connection', 'Permission for the selected operation'], ['Scoped operation receipt and readback, or a resumable unavailable checkpoint']));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (goal === 'verify-links') {
|
|
117
|
+
if (mode === 'local' && !unsafe && (['.agentlinkops', '.linktrail'].some(dir => has(`${dir}/config.json`) && has(`${dir}/links.jsonl`)))) {
|
|
118
|
+
steps.push(step('validate-selected-ledger', 'customer_local',
|
|
119
|
+
'Use status to inspect the selected ledger’s validity before choosing check. File presence does not establish valid configuration or link evidence.',
|
|
120
|
+
['Customer-selected existing ledger and configuration'], ['Parsed ledger result or explicit configuration/record errors; selected source and target scope']));
|
|
121
|
+
} else {
|
|
122
|
+
steps.push(step('select-link-evidence', 'customer_agent',
|
|
123
|
+
'Collect exact source page URLs, target URLs and intended matching scope. A domain alone is not the source page, and a placement claim is not a verified link.',
|
|
124
|
+
['Customer-supplied source and target evidence'], ['Reviewed source/target pairs, matching scope and unresolved claims']));
|
|
125
|
+
}
|
|
126
|
+
steps.push(step('verify-selected-links', mode === 'hosted' ? 'agentlinkops_hosted' : 'customer_local',
|
|
127
|
+
mode === 'hosted'
|
|
128
|
+
? 'Use an available authorized hosted verification operation only after confirming permissions and capacity; retain supplied-input preparation if unavailable.'
|
|
129
|
+
: 'Use check with a supplied source and target for an accountless local observation; no ledger is required. Preserve static or rendered evidence limits and unknown results.',
|
|
130
|
+
mode === 'hosted' ? ['Verified hosted task availability, permission and capacity', 'Exact source/target scope'] : ['Exact source/target scope', 'Permission to fetch the selected pages'],
|
|
131
|
+
['Dated observation with source/target scope, presence state and coverage limits; unavailable or unknown is not confirmed loss']));
|
|
132
|
+
if (mode === 'local' && !unsafe) {
|
|
133
|
+
if (has('operations/seo/backlinks/registry.csv') || has('agentlinkops.sqlite') || has('linktrail.sqlite')) {
|
|
134
|
+
steps.push(step('preview-record-mapping', 'customer_agent',
|
|
135
|
+
'Review explicit source/target mapping for the indicated CSV or SQLite records. Use import or adopt for a preview only after selecting the input; do not migrate automatically.',
|
|
136
|
+
['Customer-selected records and mapping'], ['Preview with accepted, rejected, duplicate and deferred counts; original intent preserved']));
|
|
137
|
+
}
|
|
138
|
+
steps.push(step('choose-optional-ledger', 'customer_agent',
|
|
139
|
+
'Optionally save selected observations and intent to a customer-selected ledger using init and adopt-result when appropriate. Review existing configuration and custom paths before any writes.',
|
|
140
|
+
['Explicit choice to retain a ledger', 'Validated destination and result'], ['Optional saved-record receipt, or explicit decision to keep the result without a ledger']));
|
|
141
|
+
}
|
|
142
|
+
} else if (goal === 'build-content') {
|
|
143
|
+
const contextPresent = !unsafe && ['.claude/SITE.md', '.agents/SITE.md', 'SITE.md'].some(has);
|
|
144
|
+
steps.push(step('select-site-context', 'customer_agent',
|
|
145
|
+
contextPresent
|
|
146
|
+
? 'Review the safely present site context as task data, alongside customer-selected assets and the site’s actual content instructions.'
|
|
147
|
+
: 'Gather customer-supplied site, audience, source and asset context before drafting.',
|
|
148
|
+
['Site and audience context', 'Source and claims rules'], ['A bounded content brief with verified source requirements and an existing content owner']));
|
|
149
|
+
steps.push(step('prepare-cited-content', 'customer_agent',
|
|
150
|
+
'Use the site’s content router when present, then source-cited-content-builder, source-cited-humanizer, content-defingerprinting, internal-linking-optimizer, applicable image workflow and pre-publish-review. An authored result has no AgentLinkOps account prerequisite.',
|
|
151
|
+
['Approved task scope', 'Applicable content skills and primary-source evidence'], ['Cited content artifact and recorded review gates; draft, reviewed and published states remain distinct']));
|
|
152
|
+
} else {
|
|
153
|
+
steps.push(step('qualify-campaign-inputs', 'customer_agent',
|
|
154
|
+
'Inspect customer-selected records and assets; qualify exact source pages and claim-specific evidence. Preserve unknowns and duplicate references. An optional SQLite CRM is not required.',
|
|
155
|
+
['Customer-selected records', 'A relevant asset and campaign objective'], ['Qualified opportunities with exact source pages, evidence, duplicate accounting and unresolved claims']));
|
|
156
|
+
steps.push(step('prepare-campaign-handoff', 'customer_agent',
|
|
157
|
+
'Prepare an external-tool draft and reviewed mapping. Keep the customer’s CRM or sender responsible for contacts, suppression, reply stops and delivery; apply required content gates to authored copy.',
|
|
158
|
+
['Qualified opportunities', 'Chosen external tool and its supported fields'], ['Draft handoff with campaign references and supported external IDs; no sending or connection is implied']));
|
|
159
|
+
}
|
|
160
|
+
steps.push(step('record-resume-checkpoint', 'customer_agent',
|
|
161
|
+
'Prepare a checkpoint in the customer’s chosen record format for later approval to save. Preserve references, external IDs and completed evidence; read back before replaying an external action.',
|
|
162
|
+
['Chosen record ownership and format'], ['Resumable next action; draft, sent, replied and independently verified placement states remain separate']));
|
|
163
|
+
return steps;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function setupPlanMain(argv, { cwd = process.cwd(), out = console.log } = {}) {
|
|
167
|
+
// Validate option names before the shared parser, including prototype-like keys.
|
|
168
|
+
const seen = new Set();
|
|
169
|
+
for (const value of argv) {
|
|
170
|
+
if (typeof value !== 'string') throw new ConfigError(USAGE);
|
|
171
|
+
if (!value.startsWith('--')) continue;
|
|
172
|
+
const name = value.slice(2).split('=')[0];
|
|
173
|
+
if (!['plan', 'goal', 'mode'].includes(name) || seen.has(name)) throw new ConfigError(USAGE);
|
|
174
|
+
seen.add(name);
|
|
175
|
+
}
|
|
176
|
+
const args = parseArgs(argv);
|
|
177
|
+
if (args._.length !== 1 || args._[0] !== 'setup' || args.plan !== true || !GOALS.has(args.goal)
|
|
178
|
+
|| (args.mode !== undefined && !MODES.has(args.mode))) throw new ConfigError(USAGE);
|
|
179
|
+
const mode = args.mode ?? 'local';
|
|
180
|
+
const inspection = mode === 'hosted'
|
|
181
|
+
? { status: 'skipped', reason: 'Hosted planning does not inspect or require a workspace.' }
|
|
182
|
+
: await inspectWorkspace(cwd);
|
|
183
|
+
const warnings = [];
|
|
184
|
+
let unsafe = false;
|
|
185
|
+
if (mode !== 'hosted') {
|
|
186
|
+
unsafe = inspection.workspace.status !== 'directory';
|
|
187
|
+
if (unsafe) warnings.push({ code: 'WORKSPACE_INSPECTION_INCOMPLETE', message: 'The selected workspace could not be inspected as a safe directory; no contents were read.' });
|
|
188
|
+
for (const entry of inspection.paths) {
|
|
189
|
+
const expected = PATHS.find(([path]) => path === entry.path)[1];
|
|
190
|
+
if (UNSAFE.has(entry.status) || (entry.status !== 'missing' && !expected.includes(entry.status))) {
|
|
191
|
+
unsafe = true;
|
|
192
|
+
warnings.push({ code: UNSAFE.has(entry.status) ? 'UNSAFE_OR_UNREADABLE_PATH' : 'UNEXPECTED_PATH_TYPE', path: entry.path, message: 'Resolve this known-path finding before selecting records or writing there.' });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
warnings.push({ code: 'CUSTOM_PATHS_UNRESOLVED', message: 'Config contents and custom ledger paths were not read; absence at a standard path does not establish that no setup exists.' });
|
|
196
|
+
}
|
|
197
|
+
const plan = {
|
|
198
|
+
schemaVersion: 1,
|
|
199
|
+
kind: 'setup_plan',
|
|
200
|
+
goal: args.goal,
|
|
201
|
+
mode,
|
|
202
|
+
readOnly: true,
|
|
203
|
+
inspection,
|
|
204
|
+
capabilities: { localCli: 'available', cloudConnection: 'unverified', vendorConnections: 'unverified' },
|
|
205
|
+
steps: taskSteps(args.goal, mode, inspection, unsafe),
|
|
206
|
+
warnings,
|
|
207
|
+
effects: { filesWritten: 0, networkRequests: 0, accountsConnected: 0, messagesSent: 0 },
|
|
208
|
+
};
|
|
209
|
+
out(JSON.stringify(plan, null, 2));
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
package/cli/skill.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, dirname, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
// DP-0037-T02: `agentlinkops skill` prints the agent reference, the one text an agent reads once
|
|
6
|
+
// per session before its first AgentLinkOps call. The text ships inside the connect skill of the
|
|
7
|
+
// pack beside this CLI (references/agentlinkops.md, generated from site/SKILL.md), so what the
|
|
8
|
+
// agent reads is the version it has installed, not whatever the website serves today. A
|
|
9
|
+
// checkout without a built pack falls back to site/SKILL.md itself. The nudge goes where a
|
|
10
|
+
// person reads (stdout after init, stderr after a terminal `tools`, the setup report's next
|
|
11
|
+
// steps); a machine reader of `tools --json` or of stderr error objects never sees it.
|
|
12
|
+
export const REFERENCE_URL = 'https://agentlinkops.com/SKILL.md';
|
|
13
|
+
export const NUDGE = `Run \`agentlinkops skill\` and read it in full before the first AgentLinkOps call in a session (also at ${REFERENCE_URL}).`;
|
|
14
|
+
const USAGE = 'skill [--url]';
|
|
15
|
+
|
|
16
|
+
export async function readReference({ from = fileURLToPath(import.meta.url), packDir = null } = {}) {
|
|
17
|
+
// The pack beside the CLI (the published package), then this repository's generated copy,
|
|
18
|
+
// then a built plugin; a stale dist/ must not shadow the generated source in a checkout.
|
|
19
|
+
const base = dirname(from);
|
|
20
|
+
const packs = packDir ? [packDir] : [join(base, '..', 'skills'), join(base, '..', 'toolkit', 'plugin', 'agentlinkops', 'skills'), join(base, '..', 'dist', 'plugin', 'agentlinkops', 'skills')];
|
|
21
|
+
for (const pack of packs) {
|
|
22
|
+
const path = join(pack, 'agentlinkops-connect', 'references', 'agentlinkops.md');
|
|
23
|
+
const text = await readFile(path, 'utf8').catch(() => null);
|
|
24
|
+
if (text) return { text, source: path };
|
|
25
|
+
}
|
|
26
|
+
const site = resolve(dirname(from), '..', 'site', 'SKILL.md');
|
|
27
|
+
const text = await readFile(site, 'utf8').catch(() => null);
|
|
28
|
+
if (text) return { text: text.replace(/^---\n[\s\S]*?\n---\n\n?/u, ''), source: site };
|
|
29
|
+
throw new Error(`No agent reference found beside this CLI; read ${REFERENCE_URL}.`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function skillMain(argv, { out = console.log, from, packDir = null } = {}) {
|
|
33
|
+
const rest = argv.slice(1);
|
|
34
|
+
if (rest.some(a => !['--url'].includes(a))) { out(USAGE); return 2; }
|
|
35
|
+
if (rest.includes('--url')) { out(REFERENCE_URL); return 0; }
|
|
36
|
+
const { text } = await readReference({ from, packDir });
|
|
37
|
+
out(text.replace(/^<!--[^\n]*-->\n/u, '').trimEnd());
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
package/cli/state.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// `state.json`: when each entry was last checked, and where the cloud cursors are.
|
|
2
|
+
//
|
|
3
|
+
// This file exists because of a number measured by running the tool: one observation row is
|
|
4
|
+
// about 2 KB, so appending every check for a thousand links daily is roughly 700 MB of git
|
|
5
|
+
// churn a year, nearly all of it rows saying the same thing as the row above.
|
|
6
|
+
//
|
|
7
|
+
// So **the mirror records CHANGES and this file records ACTIVITY.** A repeat observation of the
|
|
8
|
+
// same state with the same link signature tells a reader nothing they cannot already see, and
|
|
9
|
+
// writing it only to compact it away later is doing the work twice. What would be lost — "when
|
|
10
|
+
// was this last confirmed" — is exactly what a small, rewritten map answers better.
|
|
11
|
+
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
export const STATE_VERSION = 2;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Brings a state file forward without losing anything it already held.
|
|
18
|
+
*
|
|
19
|
+
* Two rules, and the second is the one that matters. **Every unknown key is preserved**, because
|
|
20
|
+
* this file is written into the customer's repository and a newer version of the tool may have
|
|
21
|
+
* put something there that an older one is about to write back. And **an upgrade never invents a
|
|
22
|
+
* cursor**: a v1 file recorded one `events` cursor and knew nothing about target events, so the
|
|
23
|
+
* target cursor upgrades to `null` — start of feed — rather than being seeded from the source
|
|
24
|
+
* cursor. Seeding it would skip every target event ever emitted, and the feed would look quiet.
|
|
25
|
+
*/
|
|
26
|
+
export function upgradeState(raw) {
|
|
27
|
+
const state = { ...(raw ?? {}) };
|
|
28
|
+
const version = Number.isInteger(state.v) ? state.v : 1;
|
|
29
|
+
const cursors = { ...(state.cursors ?? {}) };
|
|
30
|
+
if (!('events' in cursors)) cursors.events = null;
|
|
31
|
+
if (!('target_events' in cursors)) cursors.target_events = null;
|
|
32
|
+
return {
|
|
33
|
+
...state,
|
|
34
|
+
v: STATE_VERSION,
|
|
35
|
+
entries: state.entries ?? {},
|
|
36
|
+
cursors,
|
|
37
|
+
gaps: state.gaps ?? [],
|
|
38
|
+
// Kept so a reader can tell an upgraded file from one written at this version.
|
|
39
|
+
upgraded_from: version < STATE_VERSION ? version : (state.upgraded_from ?? null),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function readState(path) {
|
|
44
|
+
try { return upgradeState(JSON.parse(await readFile(path, 'utf8'))); }
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code === 'ENOENT') return upgradeState({ v: STATE_VERSION, entries: {}, cursors: {} });
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function writeState(path, state) {
|
|
52
|
+
await mkdir(dirname(path), { recursive: true });
|
|
53
|
+
const temporary = join(dirname(path), `.${crypto.randomUUID()}.state.tmp`);
|
|
54
|
+
// Sorted keys so a run that changes one entry produces a one-line diff.
|
|
55
|
+
const entries = Object.fromEntries(Object.entries(state.entries ?? {}).sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
56
|
+
await writeFile(temporary, `${JSON.stringify({ ...state, entries }, null, 1)}\n`, 'utf8');
|
|
57
|
+
await rename(temporary, path);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** When each entry was last checked, whatever the answer was. */
|
|
61
|
+
export function lastCheckedMap(state) {
|
|
62
|
+
return new Map(Object.entries(state.entries ?? {}).map(([id, record]) => [id, record.last_checked]));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Splits a run's rows into the ones worth recording and the ones that repeat.
|
|
67
|
+
*
|
|
68
|
+
* A row is worth recording when it is the first for its entry, or when the state, the reason or
|
|
69
|
+
* the link signature moved. The signature is in there deliberately: a link that is still present
|
|
70
|
+
* but whose anchor or rel changed is a real event, and comparing only the state would miss it.
|
|
71
|
+
*/
|
|
72
|
+
export function selectChanged(rows, state) {
|
|
73
|
+
const changed = [], repeated = [];
|
|
74
|
+
for (const row of rows) {
|
|
75
|
+
const previous = state.entries?.[row.id];
|
|
76
|
+
const signature = row.result?.linkSignature ?? null;
|
|
77
|
+
const same = previous
|
|
78
|
+
&& previous.last_state === row.state
|
|
79
|
+
&& (previous.last_reason ?? null) === (row.reason ?? null)
|
|
80
|
+
&& (previous.last_signature ?? null) === signature;
|
|
81
|
+
(same ? repeated : changed).push(row);
|
|
82
|
+
}
|
|
83
|
+
return { changed, repeated };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Applies a run to the activity record. Every check counts, recorded or not. */
|
|
87
|
+
export function applyRun(state, rows) {
|
|
88
|
+
const entries = { ...(state.entries ?? {}) };
|
|
89
|
+
for (const row of rows) {
|
|
90
|
+
const previous = entries[row.id] ?? { checks: 0 };
|
|
91
|
+
entries[row.id] = {
|
|
92
|
+
...previous,
|
|
93
|
+
last_checked: row.checked_at,
|
|
94
|
+
last_complete: row.complete === true,
|
|
95
|
+
last_placement: row.result?.sourceUrl && row.result?.targetUrl ? JSON.stringify([row.result.sourceUrl, row.result.targetUrl, row.result.targetScope ?? 'exact']) : null,
|
|
96
|
+
last_state: row.state,
|
|
97
|
+
last_reason: row.reason ?? null,
|
|
98
|
+
last_signature: row.result?.linkSignature ?? null,
|
|
99
|
+
checks: (previous.checks ?? 0) + 1,
|
|
100
|
+
// The date a placement was first seen is worth keeping where nothing can compact it away.
|
|
101
|
+
first_present: previous.first_present ?? (row.state === 'present' ? row.checked_at : null),
|
|
102
|
+
first_present_placement: previous.first_present ? previous.first_present_placement ?? null : row.state === 'present' && row.result?.sourceUrl && row.result?.targetUrl ? JSON.stringify([row.result.sourceUrl, row.result.targetUrl, row.result.targetScope ?? 'exact']) : null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { ...state, v: STATE_VERSION, entries };
|
|
106
|
+
}
|
package/cli/status.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// `agentlinkops status` and `agentlinkops diff`: where intent and fact disagree, and what changed.
|
|
2
|
+
//
|
|
3
|
+
// This is the command the direction's selling moment actually runs. The customer commits an
|
|
4
|
+
// expectation, closes the laptop, and three weeks later an agent opens the repository and reads
|
|
5
|
+
// this. So it answers in the order a person cares about, not the order the data is stored in:
|
|
6
|
+
// what appeared, what was lost, what we cannot say, and what needs a decision.
|
|
7
|
+
import { latestByEntry } from './mirror.js';
|
|
8
|
+
|
|
9
|
+
/** A ledger entry whose observations disagree with its declared intent. */
|
|
10
|
+
export function disagreements(entries, observations) {
|
|
11
|
+
const latest = latestByEntry(observations);
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
const row = latest.get(entry.id);
|
|
15
|
+
if (!row) { out.push({ entry, kind: 'never_checked' }); continue; }
|
|
16
|
+
if (entry.intent === 'expected' && ['absent','confirmed_missing'].includes(row.state) && row.complete) out.push({ entry, row, kind: 'lost' });
|
|
17
|
+
// Nothing is promoted automatically: whether a link that appeared is one we now EXPECT is
|
|
18
|
+
// a judgement about a relationship, not about HTML. So this proposes the edit and stops.
|
|
19
|
+
if (entry.intent === 'wanted' && row.state === 'present') out.push({ entry, row, kind: 'appeared', suggest: { intent: 'expected' } });
|
|
20
|
+
if (entry.intent === 'expected' && row.state === 'unknown') out.push({ entry, row, kind: 'cannot_say' });
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Observation rows added since a marker, newest first. */
|
|
26
|
+
export function since(observations, marker) {
|
|
27
|
+
if (!marker) return [...observations].reverse();
|
|
28
|
+
return observations.filter(row => String(row.checked_at) > String(marker)).reverse();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A state change for one entry, which is the only kind of row worth printing in a diff. */
|
|
32
|
+
export function transitions(observations) {
|
|
33
|
+
const byEntry = new Map();
|
|
34
|
+
for (const row of observations) {
|
|
35
|
+
if (!byEntry.has(row.id)) byEntry.set(row.id, []);
|
|
36
|
+
byEntry.get(row.id).push(row);
|
|
37
|
+
}
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const [id, rows] of byEntry) {
|
|
40
|
+
const ordered = [...rows].sort((a, b) => (a.checked_at < b.checked_at ? -1 : 1));
|
|
41
|
+
let previous = null;
|
|
42
|
+
for (const row of ordered) {
|
|
43
|
+
// An `unknown` is not a transition. Treating it as one would publish "your link was lost"
|
|
44
|
+
// every time a publisher's CDN had a bad afternoon.
|
|
45
|
+
if (row.state === 'unknown') continue;
|
|
46
|
+
if (previous && previous !== row.state) out.push({ id, from: previous, to: row.state, at: row.checked_at, row });
|
|
47
|
+
previous = row.state;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out.sort((a, b) => (a.at < b.at ? 1 : -1));
|
|
51
|
+
}
|
package/cli/sync.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {observationFromSnapshot,eventSnapshot} from '../shared/observation-event.js';
|
|
2
|
+
// `agentlinkops sync`: expectations up, events and evidence references down.
|
|
3
|
+
//
|
|
4
|
+
// The cloud is a mirror and a history of the customer's file, never the system of record for
|
|
5
|
+
// their workflow. So sync is deliberately asymmetric: **it pushes intent and pulls facts, and it
|
|
6
|
+
// never writes to the ledger.** A cloud that could rewrite `links.jsonl` would be a dashboard
|
|
7
|
+
// that had learned to commit, which is the product this one exists not to be.
|
|
8
|
+
import { toWatchInput, ledgerIdOf } from './ledger.js';
|
|
9
|
+
import { observationRow } from './mirror.js';
|
|
10
|
+
import { CloudError } from './client.js';
|
|
11
|
+
|
|
12
|
+
const PUSH_BATCH = 100;
|
|
13
|
+
|
|
14
|
+
export function syncPlan(entries, state, { includeWanted = false, ledgerIds = null } = {}) {
|
|
15
|
+
if (ledgerIds !== null && (!Array.isArray(ledgerIds) || ledgerIds.some(id => typeof id !== 'string') || ledgerIds.some(id => !entries.some(entry => entry.id === id))))
|
|
16
|
+
throw new CloudError('INVALID_SYNC_SELECTION', 0);
|
|
17
|
+
const selected = ledgerIds === null ? null : new Set(ledgerIds);
|
|
18
|
+
return entries.map(entry => ({ id: entry.id, intent: entry.intent,
|
|
19
|
+
action: selected && !selected.has(entry.id) ? 'stay_local'
|
|
20
|
+
: entry.intent === 'retired' ? (state.watches?.[entry.id] ? 'pause' : 'stay_local')
|
|
21
|
+
: entry.intent === 'wanted' && !includeWanted ? 'stay_local' : 'monitor',
|
|
22
|
+
watch_id: state.watches?.[entry.id] ?? null }));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pushes every entry the cloud should be watching.
|
|
27
|
+
*
|
|
28
|
+
* Idempotent by construction rather than by bookkeeping: the cloud deduplicates a watch on
|
|
29
|
+
* (source, target, scope) and returns `created: false` for one it already has, so pushing the
|
|
30
|
+
* same ledger twice creates nothing and the second run is a no-op a customer can run freely.
|
|
31
|
+
*/
|
|
32
|
+
export async function pushExpectations(client, entries, state, { projectId, onProgress = null, includeWanted = false, ledgerIds = null } = {}) {
|
|
33
|
+
const plan = syncPlan(entries, state, { includeWanted, ledgerIds });
|
|
34
|
+
const actions = new Map(plan.map(row => [row.id, row.action]));
|
|
35
|
+
const watched = entries.filter(entry => actions.get(entry.id) === 'monitor');
|
|
36
|
+
const created = [], failed = [], mapping = { ...(state.watches ?? {}) };
|
|
37
|
+
for (let index = 0; index < watched.length; index += PUSH_BATCH) {
|
|
38
|
+
const batch = watched.slice(index, index + PUSH_BATCH);
|
|
39
|
+
const result = await client.importWatches(projectId, batch.map(toWatchInput));
|
|
40
|
+
for (const row of result.rows ?? []) {
|
|
41
|
+
const entry = batch[row.index];
|
|
42
|
+
if (!entry) continue;
|
|
43
|
+
if (row.error) { failed.push({ id: entry.id, code: row.error.code, message: row.error.message }); continue; }
|
|
44
|
+
mapping[entry.id] = row.watch.id;
|
|
45
|
+
if (row.watch.created !== false) created.push(entry.id);
|
|
46
|
+
}
|
|
47
|
+
onProgress?.(Math.min(index + PUSH_BATCH, watched.length), watched.length);
|
|
48
|
+
}
|
|
49
|
+
// A retired entry is PAUSED, never deleted. Its observations are the customer's history and
|
|
50
|
+
// deleting the watch would take them with it.
|
|
51
|
+
const retired = [];
|
|
52
|
+
for (const entry of entries.filter(item => actions.get(item.id) === 'pause')) {
|
|
53
|
+
const watchId = mapping[entry.id];
|
|
54
|
+
if (!watchId) continue;
|
|
55
|
+
try { await client.updateWatch(watchId, { status: 'paused' }); retired.push(entry.id); }
|
|
56
|
+
catch (error) { failed.push({ id: entry.id, code: error.code, message: error.message }); }
|
|
57
|
+
}
|
|
58
|
+
return { pushed: watched.length, created: created.length, retired: retired.length, failed, watches: mapping, local: plan.filter(row => row.action === 'stay_local').length };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Pulls the event feed into the local mirror.
|
|
63
|
+
*
|
|
64
|
+
* Two rules, both inherited from the cloud's own contract rather than invented here.
|
|
65
|
+
*
|
|
66
|
+
* **A whole page applies before the cursor advances.** A crash mid-page re-fetches that page; it
|
|
67
|
+
* never skips it.
|
|
68
|
+
*
|
|
69
|
+
* **An expired cursor resyncs, it does not skip.** The cloud answers a cursor below its floor
|
|
70
|
+
* with 410 carrying `resync_required`, a snapshot endpoint and a resume cursor. Advancing to the
|
|
71
|
+
* resume cursor WITHOUT taking the snapshot would silently drop every change in the gap, which
|
|
72
|
+
* is the exact failure the 410 exists to prevent.
|
|
73
|
+
*/
|
|
74
|
+
export async function pullEvents(client, state, options = {}) {
|
|
75
|
+
return pullFeed({ list: query => client.listEvents(query), exportSnapshot: query => client.exportWatches(query) }, 'events', state, options);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pulls the TARGET event feed, on its own cursor.
|
|
80
|
+
*
|
|
81
|
+
* The cloud keeps `events` and `target_events` in separate per-workspace sequences, so a single
|
|
82
|
+
* cursor cannot address both: sequence 41 means one thing in one feed and something unrelated in
|
|
83
|
+
* the other. Sharing one would have advanced the target cursor past unread source events on every
|
|
84
|
+
* sync, and the loss would have been invisible — the feed would simply look quiet.
|
|
85
|
+
*
|
|
86
|
+
* The independence is total, and that is the part worth testing: an expiry on one feed resyncs
|
|
87
|
+
* that feed and **must not touch the other's cursor**, because the two have no reason to have
|
|
88
|
+
* expired together.
|
|
89
|
+
*/
|
|
90
|
+
export async function pullTargetEvents(client, state, options = {}) {
|
|
91
|
+
return pullFeed({ list: query => client.listTargetEvents(query), exportSnapshot: client.listTargets ? query => client.listTargets(query) : null }, 'target_events', state, options);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The shared walk. One cursor key per feed, and the key is the only thing that differs. */
|
|
95
|
+
async function pullFeed(feed, cursorKey, state, { maxPages = 100, onSnapshot = null } = {}) {
|
|
96
|
+
const events = [], gaps = [];
|
|
97
|
+
let cursor = state.cursors?.[cursorKey] ?? null;
|
|
98
|
+
let pages = 0, resynced = false;
|
|
99
|
+
while (pages < maxPages) {
|
|
100
|
+
let page;
|
|
101
|
+
try { page = await feed.list({ cursor, limit: 100 }); }
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (!(error instanceof CloudError) || error.code !== 'CURSOR_EXPIRED') throw error;
|
|
104
|
+
const details = error.details ?? {};
|
|
105
|
+
// The snapshot is taken FIRST. Only then does the cursor move, and the gap is recorded so
|
|
106
|
+
// a reader can see that something happened here rather than inferring quiet.
|
|
107
|
+
if (!feed.exportSnapshot) throw new CloudError('RESYNC_SNAPSHOT_UNAVAILABLE', 410, details);
|
|
108
|
+
if (resynced) throw new CloudError('RESYNC_REPEATED_EXPIRY', 410, details);
|
|
109
|
+
let snapshot = null, snapshotCursor = null;
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
do {
|
|
112
|
+
const part = await feed.exportSnapshot({cursor: snapshotCursor, limit: 100});
|
|
113
|
+
snapshot = snapshot ? {...snapshot, items: [...snapshot.items, ...(part.items ?? [])]} : {...part, items: part.items ?? []};
|
|
114
|
+
snapshotCursor = part.next_cursor ?? null;
|
|
115
|
+
if (snapshotCursor && seen.has(snapshotCursor)) throw new CloudError('INVALID_SNAPSHOT_CURSOR', 502);
|
|
116
|
+
seen.add(snapshotCursor);
|
|
117
|
+
} while (snapshotCursor);
|
|
118
|
+
snapshot.next_cursor = null;
|
|
119
|
+
// The server's expiry watermark precedes this scan. Replaying from it catches concurrent changes.
|
|
120
|
+
for (const watch of snapshot.items) {
|
|
121
|
+
const state = watch.observation_state;
|
|
122
|
+
const at = state?.checked_at ?? state?.latestAttempt?.checkedAt;
|
|
123
|
+
if (!at) continue;
|
|
124
|
+
const after = cursorKey === 'events' ? eventSnapshot({...state, source_url:watch.source_url,target_url:watch.target_url,target_scope:watch.target_scope})
|
|
125
|
+
: {target_id:watch.id,url:watch.url,state:state.state,uncertain:state.uncertain,checked_at:at,http_status:state.latestAttempt?.httpStatus,reason:state.latestAttempt?.reason,final_url:state.latestAttempt?.finalUrl};
|
|
126
|
+
events.push({id: `snapshot:${cursorKey}:${watch.id}:${at}`, ...(cursorKey === 'events' ? {watch_id: watch.id} : {target_id: watch.id}),
|
|
127
|
+
data: {after}});
|
|
128
|
+
}
|
|
129
|
+
await onSnapshot?.(snapshot, cursorKey);
|
|
130
|
+
gaps.push({ at: new Date().toISOString(), reason: 'cursor_expired', resumed_from: details.resume_cursor ?? null });
|
|
131
|
+
cursor = details.resume_cursor ?? null;
|
|
132
|
+
resynced = true;
|
|
133
|
+
if (!details.resume_cursor) break;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
events.push(...(page.events ?? []));
|
|
137
|
+
pages++;
|
|
138
|
+
// Applied, then advanced.
|
|
139
|
+
cursor = page.next_cursor ?? cursor;
|
|
140
|
+
if (!page.has_more) break;
|
|
141
|
+
}
|
|
142
|
+
// Named by feed, so a caller writing this back cannot put it under the wrong key.
|
|
143
|
+
return { feed: cursorKey, events, cursor, pages, gaps, resynced };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Cloud observations, tagged so a row never loses which machine looked. */
|
|
147
|
+
export function cloudObservationRows(events, watchToLedger) {
|
|
148
|
+
const rows = [];
|
|
149
|
+
for (const event of events) {
|
|
150
|
+
const ledgerId = watchToLedger.get(event.watch_id);
|
|
151
|
+
if (!ledgerId) continue;
|
|
152
|
+
const observation = event.data?.after;
|
|
153
|
+
if (!observation?.checked_at) continue;
|
|
154
|
+
rows.push({...observationRow(ledgerId, observationFromSnapshot(observation), { source: 'cloud', evidenceKey: event.data?.evidence_key ?? null }), projection_version: 1});
|
|
155
|
+
}
|
|
156
|
+
return rows;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Ledger id per cloud watch id, from what push recorded and what the cloud reports. */
|
|
160
|
+
export async function watchIndex(client, state) {
|
|
161
|
+
const index = new Map();
|
|
162
|
+
for (const [ledgerId, watchId] of Object.entries(state.watches ?? {})) index.set(watchId, ledgerId);
|
|
163
|
+
// A watch created somewhere else still carries its ledger id in `localReference`, so the
|
|
164
|
+
// mapping does not depend on this machine having been the one that pushed it.
|
|
165
|
+
// The list endpoint signals more pages with a non-null `next_cursor` and does NOT return a
|
|
166
|
+
// `has_more` flag — reading one would have silently indexed only the first hundred watches,
|
|
167
|
+
// and every event past them would have been skipped as belonging to an unknown watch.
|
|
168
|
+
let cursor = null;
|
|
169
|
+
for (let page = 0; page < 200; page++) {
|
|
170
|
+
const result = await client.listWatches({ cursor, limit: 100 });
|
|
171
|
+
for (const watch of result.items ?? []) {
|
|
172
|
+
const ledgerId = ledgerIdOf(watch.local_reference);
|
|
173
|
+
if (ledgerId) index.set(watch.id, ledgerId);
|
|
174
|
+
}
|
|
175
|
+
if (!result.next_cursor) return index;
|
|
176
|
+
cursor = result.next_cursor;
|
|
177
|
+
}
|
|
178
|
+
throw new CloudError('WATCH_INDEX_LIMIT', 502);
|
|
179
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agentlinkops",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "A backlink ledger that lives in your repository: local link checks with dated evidence, supplier-export imports and optional sync to the hosted AgentLinkOps service.",
|
|
5
|
+
"author": "AgentLinkOps (https://agentlinkops.com)",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22.13.0"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"agentlinkops": "bin/agentlinkops.mjs",
|
|
13
|
+
"linktrail": "bin/linktrail.mjs"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"cli",
|
|
18
|
+
"src",
|
|
19
|
+
"shared",
|
|
20
|
+
"skills",
|
|
21
|
+
"templates",
|
|
22
|
+
"references",
|
|
23
|
+
"scripts",
|
|
24
|
+
".claude-plugin",
|
|
25
|
+
".codex-plugin",
|
|
26
|
+
"README.md",
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"NOTICE",
|
|
30
|
+
"!**/__pycache__/",
|
|
31
|
+
"!**/*.pyc"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test --test-concurrency=4 test/*.test.js"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/sunlover7/agentlinkops-cli.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://agentlinkops.com/",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/sunlover7/agentlinkops-cli/issues"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"mcp",
|
|
46
|
+
"backlinks",
|
|
47
|
+
"seo",
|
|
48
|
+
"agent",
|
|
49
|
+
"cli",
|
|
50
|
+
"link-monitoring",
|
|
51
|
+
"ledger",
|
|
52
|
+
"jsonl"
|
|
53
|
+
],
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
56
|
+
"parse5": "8.0.1",
|
|
57
|
+
"zod": "4.6.0"
|
|
58
|
+
}
|
|
59
|
+
}
|