agentlinkops 0.6.8 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +20 -0
  4. package/README.md +44 -12
  5. package/cli/admission.js +9 -1
  6. package/cli/agent-setup.js +189 -20
  7. package/cli/catalog.js +7938 -1357
  8. package/cli/client.js +89 -23
  9. package/cli/cloud-error.js +21 -0
  10. package/cli/commands.js +37 -6
  11. package/cli/connection.js +74 -12
  12. package/cli/doctor.js +24 -9
  13. package/cli/gsc-browser.js +19 -6
  14. package/cli/ledger.js +4 -7
  15. package/cli/lifecycle.js +5 -2
  16. package/cli/local-result.js +4 -4
  17. package/cli/main.js +43 -28
  18. package/cli/mcp-client.js +198 -0
  19. package/cli/native-lock.js +22 -0
  20. package/cli/oauth-credentials.js +196 -0
  21. package/cli/oauth-flow.js +259 -0
  22. package/cli/setup-plan.js +5 -5
  23. package/cli/status.js +14 -2
  24. package/cli/sync-lock.js +105 -0
  25. package/cli/sync.js +14 -6
  26. package/package.json +6 -3
  27. package/references/cli.md +22 -0
  28. package/references/local-crm-draft-handoff.md +76 -0
  29. package/references/portable-ledger-handoff.md +50 -0
  30. package/references/recipes/catalog.json +9 -9
  31. package/references/recipes/command-notes.md +17 -1
  32. package/references/recipes/placement-reconciliation.md +19 -5
  33. package/references/recipes/qualified-campaign-handoff.md +16 -4
  34. package/references/recipes/site-context-brief.md +2 -2
  35. package/references/recipes/sourced-linkable-asset.md +8 -8
  36. package/scripts/agentlinkops.py +30 -5
  37. package/scripts/outreach-crm-draft.py +121 -0
  38. package/scripts/portable-handoff.py +184 -0
  39. package/shared/exec-grammar.js +342 -0
  40. package/shared/lifecycle-contract.js +2 -2
  41. package/shared/response-contract.js +7 -1
  42. package/skills/agentlinkops-assets/SKILL.md +2 -2
  43. package/skills/agentlinkops-campaigns/SKILL.md +3 -3
  44. package/skills/agentlinkops-connect/SKILL.md +29 -6
  45. package/skills/agentlinkops-connect/references/agentlinkops.md +62 -26
  46. package/skills/agentlinkops-crm/SKILL.md +17 -3
  47. package/src/citations/adapter.js +3 -2
  48. package/src/citations/browser/chatgpt-citations.js +60 -6
  49. package/src/citations/browser/engine.js +106 -18
  50. package/src/citations/browser/shims/utils.js +9 -2
  51. package/src/citations/login.js +42 -5
  52. package/src/discovery/contract.js +1 -1
  53. package/src/response-shaping.js +58 -0
  54. package/src/tool-views.js +31 -4
@@ -4,12 +4,19 @@
4
4
  export {
5
5
  PROVIDER_NO_OUTPUT_TIMEOUT_MS,
6
6
  PROVIDER_FORCE_EXIT_STABLE_MS,
7
- PROVIDER_EDITOR_SELECTORS,
8
7
  PROVIDER_SUBMIT_BTN_SELECTORS,
9
8
  PROVIDER_RESPONSE_GENERATION_SELECTORS,
10
9
  RETRYABLE_ERRORS,
11
10
  } from '../gen/utils/agent-constants.js';
12
- import { PROVIDER_MODEL_RESPONSE_SELECTORS as vendoredResponseSelectors } from '../gen/utils/agent-constants.js';
11
+ import { PROVIDER_MODEL_RESPONSE_SELECTORS as vendoredResponseSelectors, PROVIDER_EDITOR_SELECTORS as vendoredEditorSelectors } from '../gen/utils/agent-constants.js';
12
+
13
+ // Anonymous composer observed 2026-09-25: a plain textarea (#mobile-composer-prompt,
14
+ // name="prompt"). None of the vendored ChatGPT selectors match it; without these the engine
15
+ // reached it only through the broad shadow-DOM fallback.
16
+ export const PROVIDER_EDITOR_SELECTORS = Object.freeze({
17
+ ...vendoredEditorSelectors,
18
+ chatgpt: Object.freeze(['textarea#mobile-composer-prompt', 'textarea[name="prompt"]', ...vendoredEditorSelectors.chatgpt]),
19
+ });
13
20
 
14
21
  // Anonymous surface observed 2026-09-20: conversation LI, distinct from its
15
22
  // user sibling, with response actions. Never select the conversation itself.
@@ -8,7 +8,9 @@
8
8
  // it's never synced to the hosted service, and `citation logout ENGINE`
9
9
  // removes it. This is the CLI-own-browser posture — the user's account,
10
10
  // their risk, their data.
11
- import { mkdir, writeFile, rm, chmod } from 'node:fs/promises';
11
+ import * as fs from 'node:fs/promises';
12
+ import { rm } from 'node:fs/promises';
13
+ import { randomUUID } from 'node:crypto';
12
14
  import { join } from 'node:path';
13
15
  import { homedir } from 'node:os';
14
16
  import { readFile } from 'node:fs/promises';
@@ -25,6 +27,44 @@ const ENGINE_URLS = Object.freeze({
25
27
  bing: 'https://www.bing.com/',
26
28
  });
27
29
 
30
+ async function saveSession(sessionDir, engineName, state, { sessionFs = fs, platform = process.platform } = {}) {
31
+ const fail = () => Object.assign(new Error('Session storage requires a real private directory (0700) and regular private files (0600). Choose private storage and remove symbolic links.'), { code: 'PRIVATE_STORAGE_REQUIRED' });
32
+ const privateMode = (info, mode) => platform === 'win32' || (info.mode & 0o7777) === mode;
33
+ await sessionFs.mkdir(sessionDir, { recursive: true, mode: 0o700 });
34
+ const directory = await sessionFs.lstat(sessionDir);
35
+ if (!directory.isDirectory() || !privateMode(directory, 0o700)) throw fail();
36
+ const sessionPath = join(sessionDir, `${engineName}.json`);
37
+ const inspect = async () => {
38
+ try {
39
+ const info = await sessionFs.lstat(sessionPath);
40
+ if (!info.isFile() || !privateMode(info, 0o600)) throw fail();
41
+ return info;
42
+ } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
43
+ };
44
+ const previous = await inspect();
45
+ const temporary = join(sessionDir, `.${engineName}.${randomUUID()}.tmp`);
46
+ let handle, owned = false;
47
+ try {
48
+ handle = await sessionFs.open(temporary, 'wx', 0o600); owned = true;
49
+ const info = await handle.stat();
50
+ if (!info.isFile() || !privateMode(info, 0o600)) throw fail();
51
+ await handle.writeFile(JSON.stringify(state, null, 2));
52
+ await handle.sync();
53
+ await handle.close(); handle = undefined;
54
+ const currentDirectory = await sessionFs.lstat(sessionDir);
55
+ if (!currentDirectory.isDirectory() || !privateMode(currentDirectory, 0o700) || currentDirectory.dev !== directory.dev || currentDirectory.ino !== directory.ino) throw fail();
56
+ const current = await inspect();
57
+ if (Boolean(current) !== Boolean(previous) || (current && ['dev', 'ino', 'size', 'mtimeMs'].some(key => current[key] !== previous[key]))) {
58
+ throw Object.assign(new Error('The saved session changed during login. Retry without another session writer.'), { code: 'SESSION_CHANGED' });
59
+ }
60
+ await sessionFs.rename(temporary, sessionPath); owned = false;
61
+ return sessionPath;
62
+ } finally {
63
+ await handle?.close().catch(() => {});
64
+ if (owned) await sessionFs.unlink(temporary).catch(() => {});
65
+ }
66
+ }
67
+
28
68
  export async function loginEngine({ engineName, timeoutMs = 180_000, runtime = {} }) {
29
69
  const url = ENGINE_URLS[engineName];
30
70
  if (!url) {
@@ -80,10 +120,7 @@ export async function loginEngine({ engineName, timeoutMs = 180_000, runtime = {
80
120
  // (anonymous surfaces don't need login, and the adapter handles both).
81
121
  const state = await context.storageState();
82
122
  const sessionDir = runtime.sessionDir ?? SESSIONS_DIR;
83
- await mkdir(sessionDir, { recursive: true, mode: 0o700 });
84
- const sessionPath = join(sessionDir, `${engineName}.json`);
85
- await writeFile(sessionPath, JSON.stringify(state, null, 2), { mode: 0o600 });
86
- await chmod(sessionPath, 0o600);
123
+ const sessionPath = await saveSession(sessionDir, engineName, state, runtime);
87
124
 
88
125
  return {
89
126
  engine: engineName,
@@ -64,7 +64,7 @@ export const DiscoveryQuery = z.strictObject({
64
64
  row_limit: count.min(1).max(DISCOVERY_LIMITS.rows),
65
65
  }).superRefine((q, ctx) => {
66
66
  const url = validatePublicUrl(q.target_kind === 'domain' ? `https://${q.target}/` : q.target);
67
- if (!url.valid || (q.target_kind === 'domain' ? url.hostname !== q.target || q.target.startsWith('www.') : url.url !== q.target))
67
+ if (!url.valid || (q.target_kind === 'domain' ? url.hostname !== q.target : url.url !== q.target))
68
68
  ctx.addIssue({ code: 'custom', message: 'Invalid or noncanonical discovery target.', path: ['target'] });
69
69
  if (q.target_kind === 'exact_url' && q.include_subdomains)
70
70
  ctx.addIssue({ code: 'custom', message: 'Exact URL scope cannot include subdomains.', path: ['include_subdomains'] });
@@ -0,0 +1,58 @@
1
+ // DP-0036-T15: response shaping. A list or report command whose rows carry evidence has a
2
+ // reviewed CONCISE projection: the fields an agent needs to decide and to call next, with the
3
+ // observation facts the unknown-versus-absent rule depends on always kept (state, uncertain,
4
+ // the latest attempt and its reason, the last verified observation). MCP and code mode default
5
+ // to concise; REST and the CLI default to detailed; every caller can ask for either with
6
+ // `format`. Rows only are projected: top-level envelope fields (cursors, coverage, counts,
7
+ // reservations, run and inventory metadata) are never dropped. Rules: AGENT-SURFACE-CONTRACT.md.
8
+
9
+ export const FORMATS = ['concise','detailed'];
10
+
11
+ const pick = (row, keys) => Object.fromEntries(keys.filter(key => row[key] !== undefined).map(key => [key, row[key]]));
12
+ // The evidence core of an observation_state or an attempt: never more than these, never less.
13
+ const attempt = value => value && typeof value === 'object' ? pick(value, ['state','reason','checkedAt','httpStatus','occurrenceCount','checkerVersion']) : value;
14
+ export function conciseObservationState(state) {
15
+ if (!state || typeof state !== 'object') return state ?? {};
16
+ const out = pick(state, ['state','uncertain','checked_at','reason','confirmedState','lastChangedAt','firstAbsentAt','consecutiveAbsent','nextCheckAt','lastSuccessfulAt','lastHealthyAt','firstUnavailableAt','unavailableCount','retryNotBefore']);
17
+ if (state.latestAttempt !== undefined) out.latestAttempt = attempt(state.latestAttempt);
18
+ if (state.lastSuccessfulObservation !== undefined) out.lastSuccessfulObservation = attempt(state.lastSuccessfulObservation);
19
+ if (state.lastSuccessfulObservationSameAsLatestAttempt !== undefined) out.lastSuccessfulObservationSameAsLatestAttempt = state.lastSuccessfulObservationSameAsLatestAttempt;
20
+ return out;
21
+ }
22
+ const withState = (row, keys) => ({ ...pick(row, keys), observation_state: conciseObservationState(row.observation_state) });
23
+ // An event snapshot (shared/observation-event.js) minus the occurrence list, expectations and
24
+ // signatures: the state, uncertainty, check state, reason, status and timing stay.
25
+ const conciseSnapshot = snapshot => snapshot && typeof snapshot === 'object'
26
+ ? pick(snapshot, ['state','uncertain','check_state','reason','checked_at','http_status','occurrence_count','occurrences_truncated','source_url','target_url','url','next_check_at','watch_id','target_id'])
27
+ : snapshot;
28
+ const observationResult = result => result && typeof result === 'object'
29
+ ? { ...pick(result, ['state','reason','checkedAt','httpStatus','checkerVersion','evidence','redirects']), occurrenceCount: result.occurrenceCount ?? result.occurrences?.length ?? 0 }
30
+ : result;
31
+
32
+ // One entry per shaped command: which array holds the rows and how a row is projected. The
33
+ // `keeps` list is what the docs and the tests read; `row` is the projection itself.
34
+ export const SHAPES = {
35
+ list_rank_history: { rows: 'items', keeps: ['overview_run_id','slot_at','rank','observed_at','retrieved_at','freshness','outcome','execution','cost'],
36
+ row: r => pick(r, ['overview_run_id','slot_at','rank','observed_at','retrieved_at','freshness','outcome','execution','cost']) },
37
+ list_link_watches: { rows: 'items', keeps: ['id','source_url','target_url','status','state','observation_state','next_check_at','local_reference'], row: r => withState(r, ['id','source_url','target_url','status','state','next_check_at','local_reference']) },
38
+ list_targets: { rows: 'items', keeps: ['id','url','status','state','observation_state','next_check_at'], row: r => withState(r, ['id','url','status','state','next_check_at']) },
39
+ get_target_placements: { rows: 'items', keeps: ['id','source_url','target_url','target_scope','status','state'], row: r => pick(r, ['id','source_url','target_url','target_scope','status','state']) },
40
+ list_check_jobs: { rows: 'items', keeps: ['id','state','type','watch_id','target_id','attempt_count','error_code','created_at','completed_at'], row: r => pick(r, ['id','state','type','watch_id','target_id','attempt_count','error_code','created_at','completed_at']) },
41
+ get_history: { rows: 'items', keeps: ['id','state','reason','checked_at','job_id','evidence_key','result (state, reason, checkedAt, httpStatus, checkerVersion, evidence, redirects, occurrenceCount)'], row: r => ({ ...pick(r, ['id','state','reason','checked_at','job_id','evidence_key']), result: observationResult(r.result) }) },
42
+ list_events: { rows: 'events', keeps: ['id','type','watch_id','target_id','created_at','cursor','data (before and after snapshots without occurrences, expectations and signatures; observation_id; evidence_key)'], row: r => ({ ...pick(r, ['id','type','watch_id','target_id','created_at','cursor']), data: r.data && typeof r.data === 'object' ? { ...pick(r.data, ['observation_id','evidence_key']), ...(r.data.before !== undefined ? { before: conciseSnapshot(r.data.before) } : {}), ...(r.data.after !== undefined ? { after: conciseSnapshot(r.data.after) } : {}) } : r.data }) },
43
+ list_discovery_candidates: { rows: 'items', keeps: ['id','source_url','target_url','anchor','dofollow','provider_last_seen','verification_status','verified_at','observation_id'], row: r => pick(r, ['id','source_url','target_url','anchor','dofollow','provider_last_seen','verification_status','verified_at','observation_id']) },
44
+ list_competitor_inventory_rows: { rows: 'items', keeps: ['id','source_url','target_url','anchor','dofollow','provider_last_seen','verification_status','observation_id','member_role'], row: r => pick(r, ['id','source_url','target_url','anchor','dofollow','provider_last_seen','verification_status','observation_id','member_role']) },
45
+ get_candidate_verification_batch: { rows: 'items', keeps: ['ordinal','candidate_id','local_reference','job_id','watch_id','state','error_code','observation','usage'], row: r => pick(r, ['ordinal','candidate_id','local_reference','job_id','watch_id','state','error_code','observation','usage']) },
46
+ };
47
+ export const SHAPED_COMMANDS = Object.keys(SHAPES);
48
+
49
+ // Apply the requested (or defaulted) format. Detailed returns the data untouched apart from
50
+ // `defaults_applied`; concise projects the rows. Both formats say which defaults were applied.
51
+ export function shapeResponse(name, data, { format = null, defaultFormat = 'detailed' } = {}) {
52
+ const shape = SHAPES[name];
53
+ if (!shape || !data || typeof data !== 'object' || Array.isArray(data)) return data;
54
+ const applied = format ?? defaultFormat;
55
+ const defaults = format ? {} : { format: defaultFormat };
56
+ if (applied !== 'concise' || !Array.isArray(data[shape.rows])) return { ...data, defaults_applied: defaults };
57
+ return { ...data, [shape.rows]: data[shape.rows].map(row => row && typeof row === 'object' ? shape.row(row) : row), defaults_applied: defaults };
58
+ }
package/src/tool-views.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // script, the MCP router, the CLI index and the generators all read the same definitions.
4
4
  // Rules: docs/technical/AGENT-SURFACE-CONTRACT.md (DP-0036).
5
5
 
6
- export const TOOLSETS = ['monitoring','evidence','discovery','library','competitors','reports','workspace','notifications','webhooks','admission','disavow','lifecycle','digests'];
6
+ export const TOOLSETS = ['monitoring','evidence','rank','discovery','baseline','library','competitors','reports','workspace','notifications','webhooks','admission','disavow','lifecycle','digests'];
7
7
 
8
8
  // One line per toolset: what the group is for. Printed by the site brief, the REST index and the
9
9
  // catalog snapshot so the same wording reaches every client; a toolset without a summary is a
@@ -15,7 +15,9 @@ export const TOOLSET_SUMMARIES = {
15
15
  digests: 'Own-address digest preferences, delivery history and exact events.',
16
16
  monitoring: 'Watch earned links and destination URLs: create, list, update, import, export, recheck.',
17
17
  evidence: 'What a check observed: histories, snapshots, change feeds, check jobs, published contacts.',
18
+ rank: 'Synthetic DataForSEO rank schedules and dated history; hosted collection disabled.',
18
19
  discovery: 'Import candidate rows, read stored runs, verify selected candidates, enroll them.',
20
+ baseline: 'Scoped backlink baselines; supplier admission disabled.',
19
21
  library: 'Read and export the coverage-stated opportunity library.',
20
22
  competitors: 'Competitor sets, dated inventories, scheduled refresh, gap and domain-mix reports.',
21
23
  reports: 'Profile, anchor and report summaries over the tracked dataset.',
@@ -58,7 +60,7 @@ export function oneLiner(description, max = 60) {
58
60
  export function manifestText(entries, { max = 36 } = {}) {
59
61
  const groups = new Map(TOOLSETS.map(t => [t, []]));
60
62
  for (const entry of entries) (groups.get(entry.toolset) ?? groups.set(entry.toolset, []).get(entry.toolset)).push(entry);
61
- const lines = ['Commands by toolset (name: what it does). Use search_tools or describe_tools for the full schema before calling.'];
63
+ const lines = ['Commands by toolset (name: what it does). On /mcp use exec: `exec search <words>`, `exec describe <name>`, `exec call <name> <json>`; the CLI mirrors the verbs as `agentlinkops tools | describe | call`.'];
62
64
  for (const [toolset, members] of groups) {
63
65
  if (!members.length) continue;
64
66
  lines.push(`\n${toolset}:`);
@@ -94,11 +96,34 @@ export const CODE_TOOLS = [
94
96
  ];
95
97
  export const CODE_LIMITS = { outputChars:24000, timeoutMs:30000, maxCalls:200 };
96
98
 
99
+ // DP-0059: the default view's single exec tool. One definition carries the whole registry: the
100
+ // agent searches, inspects and calls commands through CLI-style strings instead of loading
101
+ // schemas upfront (the pattern PostHog's MCP shipped and we adopted). The definition lives here
102
+ // beside META_TOOLS and CODE_TOOLS so the measurement script counts the real shape the router
103
+ // registers; the four meta tools remain registered on /mcp for direct calls by clients that
104
+ // learned them, unlisted.
105
+ export const EXEC_TOOL = {
106
+ name: 'exec',
107
+ description: 'Run any AgentLinkOps command through one CLI-style command string: search finds commands by task, describe shows an exact schema, call runs it (reads and writes, under this connection\'s scopes). Find with search, describe once, then call and reuse the schema; never guess a schema. One command per call; several exec calls can run in parallel.',
108
+ readOnly: false,
109
+ inputSchema: {
110
+ type: 'object',
111
+ properties: {
112
+ command: {
113
+ type: 'string', minLength: 1, maxLength: 8000,
114
+ description: 'CLI-style command. Verbs: search [--toolset T] [--limit N] <words>; tools [toolset]; describe [--output-schema] [--examples] <name>...; schema <name> [<field.path>]; call [--json] <name> <json_input>. --json returns full rows instead of the concise projection. A schema larger than the context budget is summarized; drill any field carrying a hint with schema <name> <field.path>. Unknown name: run search <words>.',
115
+ },
116
+ },
117
+ required: ['command'],
118
+ additionalProperties: false,
119
+ },
120
+ };
121
+
97
122
  // Views. `entries` carry name, toolset, tier, readOnly and (from tool-exposure.js) unavailable.
98
123
  // An unavailable command stays a member of its views: it is listed with its marker, never
99
124
  // hidden, so a client that calls it gets the stable configuration error rather than not-found.
100
125
  export const VIEW_BUDGETS = {
101
- default: { definitions:12, tokens:6000 },
126
+ default: { definitions:1, tokens:3500 },
102
127
  toolset: { definitions:22, tokens:8000 },
103
128
  code: { definitions:3, tokens:2000 },
104
129
  manifest: { tokens:2500 },
@@ -108,7 +133,9 @@ export function viewMembers(entries, view) {
108
133
  const core = visible.filter(e => e.tier === 'core');
109
134
  if (view === 'all') return visible;
110
135
  if (view === 'readonly') return visible.filter(e => e.readOnly);
111
- if (view === 'default') return core;
136
+ // DP-0059: the default view lists exactly one tool, exec. Every command (core tier included)
137
+ // stays registered and callable there through exec call and by a direct tools/call.
138
+ if (view === 'default') return [];
112
139
  if (view === 'code') return [];
113
140
  const m = /^toolset:([a-z]+)(:readonly)?$/.exec(view);
114
141
  if (!m) throw new Error(`Unknown view ${view}`);