@n8r/surface 0.0.0-stage → 0.1.0-experimental.5
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/LICENSE +202 -0
- package/LICENSE-DOCS +395 -0
- package/LICENSING.md +36 -0
- package/README.md +207 -2
- package/dist/activity.d.ts +16 -0
- package/dist/activity.js +44 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +412 -0
- package/dist/codec.d.ts +6 -0
- package/dist/codec.js +56 -0
- package/dist/codex.d.ts +72 -0
- package/dist/codex.js +526 -0
- package/dist/contracts.d.ts +235 -0
- package/dist/contracts.js +1 -0
- package/dist/cyclonedx-schemas/bom-1.7.schema.json +6700 -0
- package/dist/cyclonedx-schemas/cryptography-defs.schema.json +576 -0
- package/dist/cyclonedx-schemas/jsf-0.82.schema.json +240 -0
- package/dist/cyclonedx-schemas/spdx.schema.json +786 -0
- package/dist/cyclonedx-validation.d.ts +1 -0
- package/dist/cyclonedx-validation.js +45 -0
- package/dist/html.d.ts +5 -0
- package/dist/html.js +74 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/notes.d.ts +52 -0
- package/dist/notes.js +184 -0
- package/dist/portable.d.ts +10 -0
- package/dist/portable.js +71 -0
- package/dist/recent.d.ts +9 -0
- package/dist/recent.js +71 -0
- package/dist/revisions.d.ts +2 -0
- package/dist/revisions.js +60 -0
- package/dist/settings.d.ts +23 -0
- package/dist/settings.js +100 -0
- package/dist/setup.d.ts +9 -0
- package/dist/setup.js +161 -0
- package/dist/skill-artifacts-cli.d.ts +1 -0
- package/dist/skill-artifacts-cli.js +13 -0
- package/dist/skill-artifacts.d.ts +15 -0
- package/dist/skill-artifacts.js +184 -0
- package/dist/skill-cyclonedx.d.ts +26 -0
- package/dist/skill-cyclonedx.js +214 -0
- package/dist/skill-presentation.d.ts +10 -0
- package/dist/skill-presentation.js +108 -0
- package/dist/skill-surface-cli.d.ts +1 -0
- package/dist/skill-surface-cli.js +37 -0
- package/dist/skill-surface-store.d.ts +7 -0
- package/dist/skill-surface-store.js +126 -0
- package/dist/source-tools.d.ts +102 -0
- package/dist/source-tools.js +201 -0
- package/dist/startup.d.ts +7 -0
- package/dist/startup.js +74 -0
- package/dist/surface-bom.d.ts +73 -0
- package/dist/surface-bom.js +139 -0
- package/dist/surface-draft.d.ts +22 -0
- package/dist/surface-draft.js +112 -0
- package/dist/surface-instructions.d.ts +3 -0
- package/dist/surface-instructions.js +32 -0
- package/dist/surface-overview.d.ts +15 -0
- package/dist/surface-overview.js +86 -0
- package/dist/surface-store.d.ts +34 -0
- package/dist/surface-store.js +213 -0
- package/dist/surface-view.d.ts +3 -0
- package/dist/surface-view.js +50 -0
- package/dist/validate.d.ts +4 -0
- package/dist/validate.js +482 -0
- package/docs/n8r-surface/README.md +133 -0
- package/docs/n8r-surface/cli-bundles.md +72 -0
- package/docs/n8r-surface/codex-research-transcript.md +51 -0
- package/docs/n8r-surface/codex-research-verification.md +176 -0
- package/docs/n8r-surface/command-outcomes.md +711 -0
- package/docs/n8r-surface/contracts.md +220 -0
- package/docs/n8r-surface/critical-path.md +184 -0
- package/docs/n8r-surface/design.md +289 -0
- package/docs/n8r-surface/first-use-access.md +143 -0
- package/docs/n8r-surface/first-use-overview.md +262 -0
- package/docs/n8r-surface/getting-started.md +74 -0
- package/docs/n8r-surface/guided-journey-verification.md +137 -0
- package/docs/n8r-surface/host-contract.md +176 -0
- package/docs/n8r-surface/implementation-decisions.md +96 -0
- package/docs/n8r-surface/instance-first-research.md +209 -0
- package/docs/n8r-surface/npm-distribution.md +107 -0
- package/docs/n8r-surface/npm-release.md +315 -0
- package/docs/n8r-surface/product-direction.md +327 -0
- package/docs/n8r-surface/recreation-handoff.md +117 -0
- package/docs/n8r-surface/researcher-discovery.md +157 -0
- package/docs/n8r-surface/researcher-prepared-check.md +117 -0
- package/docs/n8r-surface/researcher-reconciliation-trial.md +149 -0
- package/docs/n8r-surface/researcher-repository-check.md +95 -0
- package/docs/n8r-surface/researcher-skeleton-check.md +84 -0
- package/docs/n8r-surface/researcher-skill-verification.md +725 -0
- package/docs/n8r-surface/researcher-skill.md +309 -0
- package/docs/n8r-surface/researcher-vault.md +134 -0
- package/docs/n8r-surface/save-reopen-transcript.md +211 -0
- package/docs/n8r-surface/save-reopen-verification.md +152 -0
- package/docs/n8r-surface/security-assurance-strategy.md +267 -0
- package/docs/n8r-surface/skill-bom-verification.md +142 -0
- package/docs/n8r-surface/skill-cyclonedx-verification.md +144 -0
- package/docs/n8r-surface/skill-cyclonedx.md +85 -0
- package/docs/n8r-surface/skill-manual.md +129 -0
- package/docs/n8r-surface/skill-surface-artifacts.md +234 -0
- package/docs/n8r-surface/skill-usefulness-verification.md +135 -0
- package/docs/n8r-surface/skill-workspace-automation.md +359 -0
- package/docs/n8r-surface/startup-human-check.md +172 -0
- package/docs/n8r-surface/startup-verification.md +161 -0
- package/docs/n8r-surface/startup.md +151 -0
- package/docs/n8r-surface/surface-description-contract.md +224 -0
- package/docs/n8r-surface/surface-descriptions.md +173 -0
- package/docs/n8r-surface/surface-relationships.md +231 -0
- package/docs/n8r-surface/surface-revisions-transcript.md +552 -0
- package/docs/n8r-surface/surface-revisions-verification.md +161 -0
- package/docs/n8r-surface/tester-candidate.md +158 -0
- package/docs/n8r-surface/vault-setup-verification.md +210 -0
- package/docs/n8r-surface/verification.md +117 -0
- package/docs/n8r-surface/versioning.md +172 -0
- package/docs/n8r-surface/workspace-human-check.md +101 -0
- package/docs/n8r-surface/workspace-installation.md +68 -0
- package/docs/n8r-surface/workspace-verification.md +350 -0
- package/docs/n8r-surface/workspace.md +83 -0
- package/examples/researcher/README.md +100 -0
- package/examples/researcher/composed.surface.json +430 -0
- package/examples/researcher/composed.surface.yaml +283 -0
- package/examples/researcher/cyclonedx/configured/README.md +7 -0
- package/examples/researcher/cyclonedx/configured/manifest.json +10 -0
- package/examples/researcher/cyclonedx/configured/mapping.json +323 -0
- package/examples/researcher/cyclonedx/configured/surface-records.json +1275 -0
- package/examples/researcher/cyclonedx/configured/surface.cdx.json +79 -0
- package/examples/researcher/descriptions/README.md +29 -0
- package/examples/researcher/descriptions/bound-r1.json +141 -0
- package/examples/researcher/descriptions/bound-r2.json +153 -0
- package/examples/researcher/descriptions/capability.json +167 -0
- package/examples/researcher/descriptions/research-r1.json +153 -0
- package/examples/researcher/descriptions/research-r2.json +157 -0
- package/examples/researcher/descriptions/sources.md +24 -0
- package/examples/researcher/harbor-r1.research.html +193 -0
- package/examples/researcher/harbor-r2.research.html +226 -0
- package/examples/researcher/lumen-manual-edit.pending.html +271 -0
- package/examples/researcher/lumen-r1.research.html +271 -0
- package/examples/researcher/lumen-r2.research.html +280 -0
- package/examples/researcher/relationships/README.md +34 -0
- package/examples/researcher/relationships/bound-r1.json +346 -0
- package/examples/researcher/relationships/bound-r2.json +358 -0
- package/examples/researcher/relationships/capability.json +325 -0
- package/examples/researcher/relationships/config/assistant.env.example +4 -0
- package/examples/researcher/relationships/research-r1.json +300 -0
- package/examples/researcher/relationships/research-r2.json +304 -0
- package/examples/researcher/relationships/sources.md +38 -0
- package/examples/researcher/sources/harbor-api.md +16 -0
- package/examples/researcher/sources/harbor-auth.md +8 -0
- package/examples/researcher/sources/lumen-guide.md +28 -0
- package/examples/researcher/sources/lumen-settings.md +9 -0
- package/examples/researcher/surface-bom/README.md +17 -0
- package/examples/researcher/surface-bom/initial.cdx.json +188 -0
- package/package.json +45 -4
- package/plugins/n8r-surface/.codex-plugin/plugin.json +31 -0
- package/plugins/n8r-surface/LICENSE +202 -0
- package/plugins/n8r-surface/LICENSE-DOCS +395 -0
- package/plugins/n8r-surface/LICENSING.md +39 -0
- package/plugins/n8r-surface/assets/icon.svg +5 -0
- package/plugins/n8r-surface/plugin.json +27 -0
- package/plugins/n8r-surface/skills/research/SKILL.md +163 -0
- package/plugins/n8r-surface/skills/research/agents/openai.yaml +4 -0
- package/plugins/n8r-surface/skills/research/references/artifacts.md +152 -0
- package/plugins/n8r-surface/skills/research/references/cyclonedx.md +207 -0
- package/plugins/n8r-surface/skills/research/references/discovery-map.md +178 -0
- package/plugins/n8r-surface/skills/research/references/examples/bound-r1.json +346 -0
- package/plugins/n8r-surface/skills/research/references/examples/capability.json +325 -0
- package/plugins/n8r-surface/skills/research/references/examples/research-r1.json +300 -0
- package/plugins/n8r-surface/skills/research/references/examples/surface.cdx.json +188 -0
- package/plugins/n8r-surface/skills/research/references/first-use-access.md +97 -0
- package/plugins/n8r-surface/skills/research/references/first-use-overview.md +101 -0
- package/plugins/n8r-surface/skills/research/references/instance-first.md +125 -0
- package/plugins/n8r-surface/skills/research/references/model-meanings.md +55 -0
- package/plugins/n8r-surface/skills/research/references/skeleton-surface.md +105 -0
- package/plugins/n8r-surface/skills/research/references/working-notes.md +121 -0
- package/plugins/n8r-surface/skills/research/references/workspace.md +89 -0
- package/plugins/n8r-surface/skills/research/scripts/THIRD-PARTY-NOTICES.txt +273 -0
- package/plugins/n8r-surface/skills/research/scripts/artifacts.mjs +8348 -0
- package/plugins/n8r-surface/skills/research/scripts/cyclonedx.mjs +28113 -0
- package/plugins/n8r-surface/skills/research/scripts/surface.mjs +27800 -0
- package/plugins/n8r-surface/skills/research/scripts/workspace.mjs +120 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { lstatSync, realpathSync, opendirSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { InspectionError, readBytes, problem } from './settings.js';
|
|
5
|
+
const MAX_FILE = 256 * 1024;
|
|
6
|
+
const MAX_ENTRIES = 1000;
|
|
7
|
+
const MAX_SEARCH_FILES = 100;
|
|
8
|
+
const MAX_SEARCH_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
const excludedName = (name) => name.startsWith('.') || ['node_modules', 'vendor', 'dist'].includes(name)
|
|
10
|
+
|| /\.(pem|key|p12|pfx)$/i.test(name);
|
|
11
|
+
/** Local read capability. Directory topology must remain stable during an operation. */
|
|
12
|
+
export class SourceTools {
|
|
13
|
+
root;
|
|
14
|
+
file;
|
|
15
|
+
identity;
|
|
16
|
+
constructor(source) {
|
|
17
|
+
const selected = realpathSync(resolve(source));
|
|
18
|
+
const stat = lstatSync(selected);
|
|
19
|
+
if (!stat.isDirectory() && !stat.isFile())
|
|
20
|
+
throw new InspectionError('Select a directory or regular file');
|
|
21
|
+
this.file = stat.isFile() ? basename(selected) : undefined;
|
|
22
|
+
this.root = this.file ? dirname(selected) : selected;
|
|
23
|
+
const rootStat = lstatSync(this.root);
|
|
24
|
+
this.identity = { dev: rootStat.dev, ino: rootStat.ino };
|
|
25
|
+
}
|
|
26
|
+
get selected() { return this.file ? join(this.root, this.file) : this.root; }
|
|
27
|
+
checkRoot() {
|
|
28
|
+
const stat = lstatSync(this.root);
|
|
29
|
+
if (!stat.isDirectory() || realpathSync(this.root) !== this.root || stat.dev !== this.identity.dev || stat.ino !== this.identity.ino)
|
|
30
|
+
throw new InspectionError('Source scope changed; start a new discovery session');
|
|
31
|
+
}
|
|
32
|
+
path(path) {
|
|
33
|
+
this.checkRoot();
|
|
34
|
+
if (typeof path !== 'string' || !path || isAbsolute(path) || /[\\\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(path))
|
|
35
|
+
throw new InspectionError('Invalid source path');
|
|
36
|
+
if (path === '.' && !this.file)
|
|
37
|
+
return this.root;
|
|
38
|
+
const parts = path.split('/');
|
|
39
|
+
if (parts.some(p => !p || p === '.' || p === '..'))
|
|
40
|
+
throw new InspectionError('Path escapes source scope');
|
|
41
|
+
if (this.file && path !== this.file)
|
|
42
|
+
throw new InspectionError('Path outside file-only scope');
|
|
43
|
+
let current = this.root;
|
|
44
|
+
for (const part of parts) {
|
|
45
|
+
if (excludedName(part))
|
|
46
|
+
throw new InspectionError('Path excluded from source scope');
|
|
47
|
+
current = join(current, part);
|
|
48
|
+
if (lstatSync(current).isSymbolicLink())
|
|
49
|
+
throw new InspectionError('Symbolic links are outside source scope');
|
|
50
|
+
}
|
|
51
|
+
return current;
|
|
52
|
+
}
|
|
53
|
+
list(path = '.') {
|
|
54
|
+
this.checkRoot();
|
|
55
|
+
if (this.file) {
|
|
56
|
+
if (path !== '.')
|
|
57
|
+
throw new InspectionError('Only . can be listed in file-only scope');
|
|
58
|
+
return excludedName(this.file) ? { entries: [], excluded: [{ path: this.file, reason: 'Excluded name' }], truncated: false }
|
|
59
|
+
: { entries: [{ path: this.file, kind: 'file' }], excluded: [], truncated: false };
|
|
60
|
+
}
|
|
61
|
+
const dir = opendirSync(this.path(path));
|
|
62
|
+
const entries = [], excluded = [];
|
|
63
|
+
let truncated = false;
|
|
64
|
+
try {
|
|
65
|
+
let count = 0;
|
|
66
|
+
for (let entry = dir.readSync(); entry !== null; entry = dir.readSync()) {
|
|
67
|
+
if (++count > MAX_ENTRIES) {
|
|
68
|
+
truncated = true;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
const child = path === '.' ? entry.name : `${path}/${entry.name}`;
|
|
72
|
+
if (excludedName(entry.name) || entry.isSymbolicLink() || (!entry.isFile() && !entry.isDirectory())) {
|
|
73
|
+
excluded.push({ path: child, reason: 'Excluded name, symbolic link or special entry' });
|
|
74
|
+
}
|
|
75
|
+
else
|
|
76
|
+
entries.push({ path: child, kind: entry.isDirectory() ? 'directory' : 'file' });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
dir.closeSync();
|
|
81
|
+
}
|
|
82
|
+
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
83
|
+
return { entries, excluded, truncated };
|
|
84
|
+
}
|
|
85
|
+
bytes(path) {
|
|
86
|
+
const bytes = readBytes(this.path(path), MAX_FILE);
|
|
87
|
+
if (bytes.includes(0))
|
|
88
|
+
throw new InspectionError('Binary source is not supported text');
|
|
89
|
+
try {
|
|
90
|
+
return { bytes, text: new TextDecoder('utf-8', { fatal: true }).decode(bytes) };
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new InspectionError('Source is not valid UTF-8 text');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
read(path, startLine = 1, lineCount = 200) {
|
|
97
|
+
if (!Number.isSafeInteger(startLine) || startLine < 1 || !Number.isSafeInteger(lineCount) || lineCount < 1 || lineCount > 200)
|
|
98
|
+
throw new InspectionError('Invalid line range (1–200 lines per read)');
|
|
99
|
+
const { bytes, text } = this.bytes(path);
|
|
100
|
+
const lines = text.split('\n');
|
|
101
|
+
const selected = [];
|
|
102
|
+
let length = 0;
|
|
103
|
+
for (let n = startLine - 1; n < Math.min(lines.length, startLine - 1 + lineCount); n++) {
|
|
104
|
+
const content = `${n + 1}: ${lines[n]}`;
|
|
105
|
+
if (length + content.length > 32 * 1024)
|
|
106
|
+
break;
|
|
107
|
+
selected.push(content);
|
|
108
|
+
length += content.length + 1;
|
|
109
|
+
}
|
|
110
|
+
return { path, digest: `sha256:${createHash('sha256').update(bytes).digest('hex')}`,
|
|
111
|
+
inspectedAt: new Date().toISOString(), startLine, endLine: startLine + selected.length - 1,
|
|
112
|
+
totalLines: lines.length, text: selected.join('\n'), truncated: startLine > 1 || selected.length < lines.length };
|
|
113
|
+
}
|
|
114
|
+
search(query) {
|
|
115
|
+
if (typeof query !== 'string' || !query || query.length > 200 || /[\r\n\0]/.test(query))
|
|
116
|
+
throw new InspectionError('Invalid literal search query (1–200 characters, one line)');
|
|
117
|
+
const matches = [];
|
|
118
|
+
const excluded = [];
|
|
119
|
+
const pending = ['.'];
|
|
120
|
+
let files = 0, bytesRead = 0, entriesSeen = 0, truncated = false;
|
|
121
|
+
outer: while (pending.length) {
|
|
122
|
+
const directory = pending.shift();
|
|
123
|
+
let listing;
|
|
124
|
+
try {
|
|
125
|
+
listing = this.list(directory);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
excluded.push({ path: directory, reason: problem(error) });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
truncated ||= listing.truncated;
|
|
132
|
+
excluded.push(...listing.excluded.slice(0, MAX_ENTRIES - excluded.length));
|
|
133
|
+
entriesSeen += listing.excluded.length;
|
|
134
|
+
if (entriesSeen > MAX_ENTRIES) {
|
|
135
|
+
truncated = true;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
for (const entry of listing.entries) {
|
|
139
|
+
if (++entriesSeen > MAX_ENTRIES || files >= MAX_SEARCH_FILES || bytesRead >= MAX_SEARCH_BYTES || matches.length >= 100) {
|
|
140
|
+
truncated = true;
|
|
141
|
+
break outer;
|
|
142
|
+
}
|
|
143
|
+
if (entry.kind === 'directory') {
|
|
144
|
+
if (entry.path.split('/').length < 32)
|
|
145
|
+
pending.push(entry.path);
|
|
146
|
+
else {
|
|
147
|
+
truncated = true;
|
|
148
|
+
excluded.push({ path: entry.path, reason: 'Search depth limit' });
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
files++;
|
|
153
|
+
try {
|
|
154
|
+
const { bytes, text } = this.bytes(entry.path);
|
|
155
|
+
bytesRead += bytes.length;
|
|
156
|
+
const digest = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
157
|
+
const lines = text.split('\n');
|
|
158
|
+
for (let n = 0; n < lines.length; n++)
|
|
159
|
+
if (lines[n].includes(query)) {
|
|
160
|
+
matches.push({ path: entry.path, line: n + 1, text: lines[n].slice(0, 1000), digest });
|
|
161
|
+
if (matches.length >= 100) {
|
|
162
|
+
truncated = true;
|
|
163
|
+
break outer;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
excluded.push({ path: entry.path, reason: problem(error) });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { query, matches, excluded, truncated, filesInspected: files, inspectedAt: new Date().toISOString() };
|
|
173
|
+
}
|
|
174
|
+
call(tool, args) {
|
|
175
|
+
if (!args || typeof args !== 'object' || Array.isArray(args))
|
|
176
|
+
throw new InspectionError('Invalid tool arguments');
|
|
177
|
+
const a = args;
|
|
178
|
+
const keys = tool === 'source_list' ? ['path'] : tool === 'source_read' ? ['path', 'startLine', 'lineCount'] : tool === 'source_search' ? ['query'] : undefined;
|
|
179
|
+
if (!keys)
|
|
180
|
+
throw new InspectionError('Unknown source tool');
|
|
181
|
+
if (Object.keys(a).some(k => !keys.includes(k)))
|
|
182
|
+
throw new InspectionError('Invalid tool arguments');
|
|
183
|
+
if (tool === 'source_search')
|
|
184
|
+
return this.search(a.query);
|
|
185
|
+
if (a.path !== undefined && typeof a.path !== 'string')
|
|
186
|
+
throw new InspectionError('Invalid tool arguments');
|
|
187
|
+
if (tool === 'source_list')
|
|
188
|
+
return this.list(a.path);
|
|
189
|
+
if (typeof a.path !== 'string')
|
|
190
|
+
throw new InspectionError('Invalid tool arguments');
|
|
191
|
+
return this.read(a.path, a.startLine, a.lineCount);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
export const sourceToolSpecs = [
|
|
195
|
+
{ name: 'source_list', description: 'List a directory in the authorized local source. Start with path ".". Returns explicit omissions and truncation.',
|
|
196
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false } },
|
|
197
|
+
{ name: 'source_read', description: 'Read UTF-8 source evidence with line numbers and exact full-file SHA-256. Paths are relative to source; no shell or writes. Maximum 200 lines per call.',
|
|
198
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' }, startLine: { type: 'integer' }, lineCount: { type: 'integer' } }, required: ['path'], additionalProperties: false } },
|
|
199
|
+
{ name: 'source_search', description: 'Bounded recursive literal case-sensitive search within authorized source text. Reports coverage omissions. Use source_read to inspect matching context.',
|
|
200
|
+
inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'], additionalProperties: false } }
|
|
201
|
+
];
|
package/dist/startup.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { extname, join } from 'node:path';
|
|
4
|
+
import { decode } from './codec.js';
|
|
5
|
+
import { readResearchHtml } from './html.js';
|
|
6
|
+
import { line, problem, readLocal, InspectionError, loadSettings } from './settings.js';
|
|
7
|
+
function artefacts(root) {
|
|
8
|
+
let entries;
|
|
9
|
+
try {
|
|
10
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
return { lines: ['Local artefacts', ` ${line(root)} | Folder unavailable (${problem(error)})`], failed: error?.code !== 'ENOENT' };
|
|
14
|
+
}
|
|
15
|
+
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
16
|
+
const lines = [`Local artefacts (${entries.length})`];
|
|
17
|
+
let failed = false;
|
|
18
|
+
if (!entries.length)
|
|
19
|
+
lines.push(' Folder is empty');
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
const path = join(root, entry.name);
|
|
22
|
+
try {
|
|
23
|
+
if (entry.isSymbolicLink())
|
|
24
|
+
throw new InspectionError('Symlink not followed');
|
|
25
|
+
if (!entry.isFile())
|
|
26
|
+
throw new InspectionError('Not a regular file');
|
|
27
|
+
const extension = extname(entry.name).toLowerCase();
|
|
28
|
+
if (!['.json', '.yaml', '.yml', '.html'].includes(extension))
|
|
29
|
+
throw new InspectionError('Unsupported file type');
|
|
30
|
+
const text = readLocal(path, 2 * 1024 * 1024);
|
|
31
|
+
let value;
|
|
32
|
+
try {
|
|
33
|
+
value = extension === '.html' ? readResearchHtml(text) : decode(text, extension === '.json' ? 'json' : 'yaml');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new InspectionError('Invalid or unsupported artefact; content not displayed');
|
|
37
|
+
}
|
|
38
|
+
const type = value.contract === 'n8r.surface/0.1' ? 'Surface definition' : 'Research record';
|
|
39
|
+
lines.push(` ${line(value.title)} | ${type} | ${line(path)}`);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
failed = true;
|
|
43
|
+
lines.push(` ${line(path)} | ${problem(error)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { lines, failed };
|
|
47
|
+
}
|
|
48
|
+
export function overview() {
|
|
49
|
+
const home = homedir();
|
|
50
|
+
const file = join(home, '.n8r/n8r-surface.json');
|
|
51
|
+
const lines = ['Configuration', ` File: ${line(file)}`];
|
|
52
|
+
let config;
|
|
53
|
+
let failed = false;
|
|
54
|
+
try {
|
|
55
|
+
config = loadSettings(home);
|
|
56
|
+
const text = config.original;
|
|
57
|
+
if (text === undefined)
|
|
58
|
+
lines.push(' Missing; using defaults (no file created)');
|
|
59
|
+
lines.push(` Settings version: 1${text === undefined ? ' (default)' : ''}`, ` Artefact folder: ${line(config.root)}${config.defaultRoot ? ' (default)' : ''}`, ` Vault folder: ${config.vault === undefined ? 'Not configured' : line(config.vault)}`, ` Surface output: ${config.format.toUpperCase()}${config.defaultFormat ? ' (default)' : ''}`, ` AI runner: ${config.runner ? 'Configured (unverified; details withheld)' : 'Not configured'}`, ` Lab connection: ${config.lab ? 'Configured (unverified; details withheld)' : 'Not configured'}`);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
failed = true;
|
|
63
|
+
lines.push(` Configuration unavailable: ${problem(error)}`);
|
|
64
|
+
}
|
|
65
|
+
lines.push('', 'Processes', ` n8r-surface overview: running (PID ${process.pid}); observed ${new Date().toISOString()}`, ' Runner/Lab processes: Status unknown (no live status source)', '');
|
|
66
|
+
if (config) {
|
|
67
|
+
const listed = artefacts(config.root);
|
|
68
|
+
lines.push(...listed.lines);
|
|
69
|
+
failed ||= listed.failed;
|
|
70
|
+
}
|
|
71
|
+
else
|
|
72
|
+
lines.push('Local artefacts', ' Not inspected: configuration unavailable');
|
|
73
|
+
return { text: lines.join('\n') + '\n', failed, configured: config !== undefined, settings: config };
|
|
74
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type Bom = Record<string, any>;
|
|
2
|
+
export interface Relationship {
|
|
3
|
+
id: string;
|
|
4
|
+
from: string;
|
|
5
|
+
to: string;
|
|
6
|
+
kind: 'dependency' | 'integration' | 'hosting' | 'data-flow';
|
|
7
|
+
optionality: 'required' | 'optional' | 'alternative' | 'unknown';
|
|
8
|
+
group: string;
|
|
9
|
+
support: 'supported' | 'unknown' | 'absent' | 'conflicting' | 'out-of-scope';
|
|
10
|
+
selection: 'selected' | 'unknown' | 'not-selected' | 'conflicting';
|
|
11
|
+
observations: {
|
|
12
|
+
at: string;
|
|
13
|
+
evidence: string[];
|
|
14
|
+
note: string;
|
|
15
|
+
}[];
|
|
16
|
+
evidence: string[];
|
|
17
|
+
selectionEvidence: string[];
|
|
18
|
+
purpose: string;
|
|
19
|
+
mechanism: string;
|
|
20
|
+
configuration: string[];
|
|
21
|
+
conditions: string[];
|
|
22
|
+
note: string;
|
|
23
|
+
}
|
|
24
|
+
export interface Entry {
|
|
25
|
+
id: string;
|
|
26
|
+
kind: 'next' | 'question' | 'decision' | 'diversion' | 'finding';
|
|
27
|
+
title: string;
|
|
28
|
+
text: string;
|
|
29
|
+
status: 'open' | 'active' | 'done' | 'deferred' | 'superseded';
|
|
30
|
+
reason: string;
|
|
31
|
+
access: string;
|
|
32
|
+
evidence: string[];
|
|
33
|
+
at: string;
|
|
34
|
+
parent?: string;
|
|
35
|
+
supersedes?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface Evidence {
|
|
38
|
+
id: string;
|
|
39
|
+
basis: 'source' | 'configuration' | 'observation' | 'user';
|
|
40
|
+
sources: {
|
|
41
|
+
index: number;
|
|
42
|
+
locator: string;
|
|
43
|
+
}[];
|
|
44
|
+
applicability: string;
|
|
45
|
+
}
|
|
46
|
+
export interface Surface {
|
|
47
|
+
bom: Bom;
|
|
48
|
+
objective: string;
|
|
49
|
+
scope: string;
|
|
50
|
+
relationships: Relationship[];
|
|
51
|
+
entries: Entry[];
|
|
52
|
+
evidence: Evidence[];
|
|
53
|
+
coverage: string[];
|
|
54
|
+
gaps: string[];
|
|
55
|
+
presentation: Record<string, Presentation>;
|
|
56
|
+
}
|
|
57
|
+
export interface Presentation {
|
|
58
|
+
boundary: 'internal' | 'external' | 'unknown';
|
|
59
|
+
area: string;
|
|
60
|
+
focus?: boolean;
|
|
61
|
+
evidence: string[];
|
|
62
|
+
}
|
|
63
|
+
export declare const prefix = "n8r:surface:";
|
|
64
|
+
export declare const property: (name: string, value: unknown) => {
|
|
65
|
+
name: string;
|
|
66
|
+
value: string;
|
|
67
|
+
};
|
|
68
|
+
export declare function projectDependencies(edges: Relationship[]): {
|
|
69
|
+
ref: string;
|
|
70
|
+
dependsOn: string[];
|
|
71
|
+
}[];
|
|
72
|
+
/** Parses only the documented profile, checking references and projection consistency. */
|
|
73
|
+
export declare function checkSurfaceBom(input: unknown): Surface;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/** Bounded Surface profile over CycloneDX 1.7; no parallel native authority. */
|
|
2
|
+
import { validateBom } from './cyclonedx-validation.js';
|
|
3
|
+
import { privacy } from './skill-artifacts.js';
|
|
4
|
+
import { canonical } from './portable.js';
|
|
5
|
+
import { decodeJsonValue } from './codec.js';
|
|
6
|
+
export const prefix = 'n8r:surface:';
|
|
7
|
+
export const property = (name, value) => ({ name: prefix + name, value: typeof value === 'string' ? value : JSON.stringify(value) });
|
|
8
|
+
function fail(reason) { throw Error(`Surface BOM refused: ${reason}. Input values withheld.`); }
|
|
9
|
+
function requireThat(ok, reason) { if (!ok)
|
|
10
|
+
fail(reason); }
|
|
11
|
+
const nonempty = (x) => typeof x === 'string' && x.trim().length > 0;
|
|
12
|
+
const strings = (x) => Array.isArray(x) && x.every(nonempty) && new Set(x).size === x.length;
|
|
13
|
+
const date = (x) => typeof x === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z$/.test(x) && Number.isFinite(Date.parse(x));
|
|
14
|
+
const keys = (x, required, optional = []) => x && typeof x === 'object' && !Array.isArray(x) && required.every(k => Object.hasOwn(x, k)) && Object.keys(x).every(k => [...required, ...optional].includes(k));
|
|
15
|
+
const unique = (xs) => new Set(xs).size === xs.length;
|
|
16
|
+
export function projectDependencies(edges) {
|
|
17
|
+
const map = new Map();
|
|
18
|
+
for (const e of edges)
|
|
19
|
+
if (e.kind === 'dependency' && e.selection === 'selected') {
|
|
20
|
+
const targets = map.get(e.from) ?? new Set();
|
|
21
|
+
targets.add(e.to);
|
|
22
|
+
map.set(e.from, targets);
|
|
23
|
+
}
|
|
24
|
+
return [...map].sort(([a], [b]) => a.localeCompare(b)).map(([ref, to]) => ({ ref, dependsOn: [...to].sort() }));
|
|
25
|
+
}
|
|
26
|
+
/** Parses only the documented profile, checking references and projection consistency. */
|
|
27
|
+
export function checkSurfaceBom(input) {
|
|
28
|
+
validateBom(input);
|
|
29
|
+
const b = structuredClone(input);
|
|
30
|
+
requireThat(nonempty(b.serialNumber) && Number.isSafeInteger(b.version) && b.version > 0 && date(b.metadata?.timestamp), 'revision identity');
|
|
31
|
+
// Safe profile does not carry endpoints/connection values. Public source URLs are
|
|
32
|
+
// supported only as externalReferences; source content itself is not persisted.
|
|
33
|
+
const safe = structuredClone(b);
|
|
34
|
+
for (const r of safe.externalReferences ?? []) {
|
|
35
|
+
requireThat(!r.url.startsWith('file:') && !r.url.includes('@') && !/[?]/.test(r.url), 'source locator privacy');
|
|
36
|
+
if (/^https:\/\//.test(r.url)) {
|
|
37
|
+
const u = new URL(r.url);
|
|
38
|
+
requireThat(!u.username && !u.password && !/^\d/.test(u.hostname) && !['localhost'].includes(u.hostname), 'source locator privacy');
|
|
39
|
+
r.url = 'urn:public-source';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
privacy(safe);
|
|
43
|
+
const refs = new Set();
|
|
44
|
+
function visit(x) { if (!x || typeof x !== 'object')
|
|
45
|
+
return; if (!Array.isArray(x) && Object.hasOwn(x, 'bom-ref')) {
|
|
46
|
+
requireThat(nonempty(x['bom-ref']) && !refs.has(x['bom-ref']), 'unique BOM references');
|
|
47
|
+
refs.add(x['bom-ref']);
|
|
48
|
+
} for (const v of Object.values(x))
|
|
49
|
+
visit(v); }
|
|
50
|
+
visit(b);
|
|
51
|
+
const nodes = new Set([...(b.components ?? []), ...(b.services ?? [])].map(n => n['bom-ref']));
|
|
52
|
+
const annotations = new Map((b.annotations ?? []).map((a) => [a['bom-ref'], a]));
|
|
53
|
+
for (const a of annotations.values())
|
|
54
|
+
requireThat(nonempty(a['bom-ref']) && a.subjects.length > 0 && a.subjects.every((id) => refs.has(id)), 'annotation subjects');
|
|
55
|
+
for (const c of b.citations ?? []) {
|
|
56
|
+
requireThat(c.pointers && !c.expressions && refs.has(c.attributedTo) && !c.process, 'citation attribution');
|
|
57
|
+
for (const p of c.pointers) {
|
|
58
|
+
requireThat(typeof p === 'string' && p.startsWith('/'), 'citation pointer');
|
|
59
|
+
let v = b;
|
|
60
|
+
for (const k of p.slice(1).split('/').map((k) => k.replace(/~1/g, '/').replace(/~0/g, '~'))) {
|
|
61
|
+
requireThat(v !== null && typeof v === 'object' && Object.hasOwn(v, k), 'citation pointer target');
|
|
62
|
+
v = v[k];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const allowed = ['profile', 'objective', 'scope', 'relationship', 'entry', 'evidence', 'coverage', 'gap', 'predecessor-digest', 'reconciliation', 'migration'];
|
|
67
|
+
const ps = b.properties ?? [];
|
|
68
|
+
for (const p of ps)
|
|
69
|
+
requireThat(p.name.startsWith(prefix) && allowed.includes(p.name.slice(prefix.length)), 'profile property');
|
|
70
|
+
const values = (key) => ps.filter((p) => p.name === prefix + key).map((p) => p.value);
|
|
71
|
+
const one = (key) => { const vs = values(key); requireThat(vs.length === 1 && nonempty(vs[0]), 'single ' + key); return vs[0]; };
|
|
72
|
+
requireThat(one('profile') === '1', 'supported profile version');
|
|
73
|
+
for (const key of ['predecessor-digest', 'reconciliation', 'migration'])
|
|
74
|
+
requireThat(values(key).length <= 1, 'single ' + key);
|
|
75
|
+
const parsed = (key) => values(key).map((s) => { try {
|
|
76
|
+
return decodeJsonValue(s);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return fail('profile JSON');
|
|
80
|
+
} });
|
|
81
|
+
const evidence = parsed('evidence'), relationships = parsed('relationship'), entries = parsed('entry');
|
|
82
|
+
requireThat(unique(evidence.map(e => e.id)) && unique(relationships.map(e => e.id)) && unique(entries.map(e => e.id)), 'unique profile IDs');
|
|
83
|
+
const evidenceIds = new Set(evidence.map(e => e.id));
|
|
84
|
+
for (const e of evidence) {
|
|
85
|
+
requireThat(keys(e, ['id', 'basis', 'sources', 'applicability']) && annotations.has(e.id) && ['source', 'configuration', 'observation', 'user'].includes(e.basis) && nonempty(e.applicability) && Array.isArray(e.sources), 'evidence record');
|
|
86
|
+
requireThat(e.basis === 'user' || e.sources.length > 0, 'source-backed evidence');
|
|
87
|
+
for (const s of e.sources)
|
|
88
|
+
requireThat(keys(s, ['index', 'locator']) && Number.isSafeInteger(s.index) && s.index >= 0 && b.externalReferences?.[s.index] && nonempty(s.locator), 'source reference');
|
|
89
|
+
}
|
|
90
|
+
const evidenceFor = (ids, subjects, basis) => strings(ids) && ids.every(id => evidenceIds.has(id) && subjects.every(n => annotations.get(id).subjects.includes(n)) && (!basis || basis.includes(evidence.find(e => e.id === id).basis)));
|
|
91
|
+
const presentation = Object.create(null);
|
|
92
|
+
for (const n of [...(b.components ?? []), ...(b.services ?? [])]) {
|
|
93
|
+
const hints = (n.properties ?? []).filter((p) => p.name === prefix + 'presentation');
|
|
94
|
+
requireThat(hints.length <= 1, 'single node presentation');
|
|
95
|
+
if (!hints.length)
|
|
96
|
+
continue;
|
|
97
|
+
let p;
|
|
98
|
+
try {
|
|
99
|
+
p = decodeJsonValue(hints[0].value);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return fail('node presentation JSON');
|
|
103
|
+
}
|
|
104
|
+
requireThat(keys(p, ['boundary', 'area', 'evidence'], ['focus']) && ['internal', 'external', 'unknown'].includes(p.boundary) && nonempty(p.area) && (p.focus === undefined || typeof p.focus === 'boolean'), 'node presentation shape');
|
|
105
|
+
requireThat(evidenceFor(p.evidence, [n['bom-ref']]) && (p.boundary === 'unknown' || p.evidence.length > 0), 'node presentation evidence');
|
|
106
|
+
presentation[n['bom-ref']] = p;
|
|
107
|
+
}
|
|
108
|
+
requireThat(Object.values(presentation).filter(p => p.focus).length <= 1, 'single focus application');
|
|
109
|
+
const edgeKeys = ['id', 'from', 'to', 'kind', 'optionality', 'group', 'support', 'selection', 'observations', 'evidence', 'selectionEvidence', 'purpose', 'mechanism', 'configuration', 'conditions', 'note'];
|
|
110
|
+
for (const e of relationships) {
|
|
111
|
+
requireThat(keys(e, edgeKeys) && nonempty(e.id) && nodes.has(e.from) && nodes.has(e.to), 'relationship endpoints/shape');
|
|
112
|
+
requireThat(['dependency', 'integration', 'hosting', 'data-flow'].includes(e.kind) && ['required', 'optional', 'alternative', 'unknown'].includes(e.optionality) && typeof e.group === 'string', 'relationship kind/optionality');
|
|
113
|
+
requireThat(e.optionality !== 'alternative' || nonempty(e.group), 'alternative group');
|
|
114
|
+
requireThat(['supported', 'unknown', 'absent', 'conflicting', 'out-of-scope'].includes(e.support) && ['selected', 'unknown', 'not-selected', 'conflicting'].includes(e.selection), 'relationship states');
|
|
115
|
+
requireThat([e.purpose, e.mechanism, e.note].every(nonempty) && strings(e.configuration) && strings(e.conditions), 'relationship detail');
|
|
116
|
+
requireThat(evidenceFor(e.evidence, [e.from, e.to]) && evidenceFor(e.selectionEvidence, [e.from, e.to], ['configuration', 'user']), 'relationship evidence');
|
|
117
|
+
requireThat(!['supported', 'absent', 'conflicting'].includes(e.support) || e.evidence.length > 0, 'support evidence');
|
|
118
|
+
requireThat(e.selection === 'unknown' || e.selectionEvidence.length > 0, 'selection evidence');
|
|
119
|
+
requireThat(e.selection !== 'selected' || !e.configuration.some(x => /\$\{|<[^>]+>|placeholder/i.test(x)), 'placeholder selection');
|
|
120
|
+
requireThat(Array.isArray(e.observations), 'observations');
|
|
121
|
+
for (const o of e.observations)
|
|
122
|
+
requireThat(keys(o, ['at', 'evidence', 'note']) && date(o.at) && nonempty(o.note) && o.evidence.length > 0 && evidenceFor(o.evidence, [e.from, e.to], ['observation']), 'dated observed evidence');
|
|
123
|
+
}
|
|
124
|
+
const entryIds = new Set(entries.map(e => e.id));
|
|
125
|
+
for (const e of entries) {
|
|
126
|
+
requireThat(keys(e, ['id', 'kind', 'title', 'text', 'status', 'reason', 'access', 'evidence', 'at'], ['parent', 'supersedes']) && [e.id, e.title, e.text, e.reason, e.access].every(nonempty) && date(e.at), 'investigation entry');
|
|
127
|
+
requireThat(['next', 'question', 'decision', 'diversion', 'finding'].includes(e.kind) && ['open', 'active', 'done', 'deferred', 'superseded'].includes(e.status), 'investigation state');
|
|
128
|
+
requireThat(strings(e.evidence) && e.evidence.every(id => evidenceIds.has(id)), 'investigation evidence');
|
|
129
|
+
requireThat(e.kind !== 'decision' || e.evidence.some(id => evidence.find(x => x.id === id)?.basis === 'user'), 'decision provenance');
|
|
130
|
+
for (const id of [e.parent, e.supersedes])
|
|
131
|
+
requireThat(id === undefined || (entryIds.has(id) && id !== e.id), 'investigation links');
|
|
132
|
+
}
|
|
133
|
+
const deps = (b.dependencies ?? []).map((d) => ({ ref: d.ref, dependsOn: [...(d.dependsOn ?? [])].sort() })).sort((a, z) => a.ref.localeCompare(z.ref));
|
|
134
|
+
requireThat((b.dependencies ?? []).every((d) => !d.provides) && canonical(deps) === canonical(projectDependencies(relationships)), 'selected dependency projection');
|
|
135
|
+
requireThat(b.compositions?.some((c) => c.aggregate === 'unknown'), 'bounded coverage declaration');
|
|
136
|
+
const coverage = values('coverage'), gaps = values('gap');
|
|
137
|
+
requireThat(strings(coverage) && strings(gaps), 'coverage/gaps');
|
|
138
|
+
return { bom: b, objective: one('objective'), scope: one('scope'), relationships, entries, evidence, coverage, gaps, presentation };
|
|
139
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Bundle } from './skill-artifacts.js';
|
|
2
|
+
import type { ResearchRecord, SurfaceDefinition, Artefact } from './contracts.js';
|
|
3
|
+
export type LegacyDraft = {
|
|
4
|
+
research: ResearchRecord;
|
|
5
|
+
surface: SurfaceDefinition;
|
|
6
|
+
};
|
|
7
|
+
export type SurfaceDraft = LegacyDraft | Bundle;
|
|
8
|
+
export declare const isBundle: (draft: SurfaceDraft) => draft is Bundle;
|
|
9
|
+
export type DraftContext = {
|
|
10
|
+
createdAt?: string;
|
|
11
|
+
previous?: SurfaceDraft;
|
|
12
|
+
};
|
|
13
|
+
export declare const revisionRef: (a: Artefact) => {
|
|
14
|
+
id: string;
|
|
15
|
+
revision: string;
|
|
16
|
+
digest: string;
|
|
17
|
+
};
|
|
18
|
+
/** Preservation beyond the library's bounded lineage checks. */
|
|
19
|
+
export declare function verifyPairSuccessor(before: SurfaceDraft, after: SurfaceDraft): void;
|
|
20
|
+
/** New bundles use the shared skill checker; legacy content keeps host-owned envelopes. */
|
|
21
|
+
export declare function prepareSurfaceDraft(input: unknown, context?: DraftContext): SurfaceDraft;
|
|
22
|
+
export declare function previewSurface(draft: SurfaceDraft): string;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { checkBundle, verifyBundleSuccessor } from './skill-artifacts.js';
|
|
2
|
+
import { renderBrief } from './skill-presentation.js';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { canonical, digest } from './portable.js';
|
|
5
|
+
import { validateArtefact, verifyReferences } from './validate.js';
|
|
6
|
+
import { verifySuccessor } from './revisions.js';
|
|
7
|
+
import { InspectionError } from './settings.js';
|
|
8
|
+
export const isBundle = (draft) => draft.surface.contract === 'n8r.surface/0.3';
|
|
9
|
+
export const revisionRef = (a) => ({ id: a.id, revision: a.revision.id, digest: digest(a) });
|
|
10
|
+
const error = (s) => { throw new InspectionError(s); };
|
|
11
|
+
function retained(before, after, label) {
|
|
12
|
+
for (const old of before)
|
|
13
|
+
if (!after.some(x => x.id === old.id))
|
|
14
|
+
error(`Preserve ${label} identity ${old.id}; deletion is outside this increment`);
|
|
15
|
+
}
|
|
16
|
+
/** Preservation beyond the library's bounded lineage checks. */
|
|
17
|
+
export function verifyPairSuccessor(before, after) {
|
|
18
|
+
if (isBundle(before) && isBundle(after)) {
|
|
19
|
+
verifyBundleSuccessor(before, after);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
verifySuccessor(before.research, after.research);
|
|
23
|
+
verifySuccessor(before.surface, after.surface);
|
|
24
|
+
for (const key of ['sources', 'findings', 'alternatives', 'decisions', 'questions'])
|
|
25
|
+
retained(before.research[key], after.research[key], key);
|
|
26
|
+
retained(before.surface.nodes, after.surface.nodes, 'component');
|
|
27
|
+
for (const source of before.research.sources) {
|
|
28
|
+
if (canonical(source) !== canonical(after.research.sources.find(x => x.id === source.id)))
|
|
29
|
+
error(`Preserve historical source ${source.id}; add a new source identity for new evidence`);
|
|
30
|
+
}
|
|
31
|
+
for (const finding of before.research.findings) {
|
|
32
|
+
const next = after.research.findings.find(x => x.id === finding.id);
|
|
33
|
+
for (const claim of finding.claims)
|
|
34
|
+
if (!next.claims.some(c => canonical(c) === canonical(claim)))
|
|
35
|
+
error(`Preserve historical claim in ${finding.id}`);
|
|
36
|
+
if (next.subject !== finding.subject || next.property !== finding.property)
|
|
37
|
+
error(`Retained finding changed subject/property: ${finding.id}`);
|
|
38
|
+
}
|
|
39
|
+
for (const note of before.research.notes)
|
|
40
|
+
if (!after.research.notes.some(n => canonical(n) === canonical(note)))
|
|
41
|
+
error('Preserve historical research note; append corrections');
|
|
42
|
+
for (const decision of before.research.decisions)
|
|
43
|
+
if (after.research.decisions.find(x => x.id === decision.id).alternative !== decision.alternative)
|
|
44
|
+
error(`Retained decision changed alternative: ${decision.id}`);
|
|
45
|
+
for (const node of before.surface.nodes)
|
|
46
|
+
if (canonical(after.surface.nodes.find(x => x.id === node.id).type) !== canonical(node.type))
|
|
47
|
+
error(`Retained component changed type: ${node.id}`);
|
|
48
|
+
for (const key of ['research', 'surface'])
|
|
49
|
+
for (const [name, value] of Object.entries(before[key].extensions)) {
|
|
50
|
+
if (!Object.hasOwn(after[key].extensions, name) || canonical(value) !== canonical(after[key].extensions[name]))
|
|
51
|
+
error(`Preserve opaque extension ${name}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** New bundles use the shared skill checker; legacy content keeps host-owned envelopes. */
|
|
55
|
+
export function prepareSurfaceDraft(input, context = {}) {
|
|
56
|
+
canonical(input); // bound size/depth and reject non-data before inspecting it
|
|
57
|
+
const raw = input;
|
|
58
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !['research,surface', 'dependencies,research,surface'].includes(Object.keys(raw).sort().join(',')))
|
|
59
|
+
error('Expected research and surface content objects');
|
|
60
|
+
if (raw?.surface?.contract === 'n8r.surface/0.3') {
|
|
61
|
+
const bundle = checkBundle(JSON.parse(JSON.stringify(raw)));
|
|
62
|
+
if (context.previous) {
|
|
63
|
+
verifyPairSuccessor(context.previous, bundle);
|
|
64
|
+
}
|
|
65
|
+
return bundle;
|
|
66
|
+
}
|
|
67
|
+
const data = JSON.parse(JSON.stringify(raw));
|
|
68
|
+
const createdAt = context.createdAt ?? new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
69
|
+
for (const key of ['research', 'surface']) {
|
|
70
|
+
const value = data[key];
|
|
71
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
72
|
+
error(`Invalid ${key} content`);
|
|
73
|
+
for (const reserved of ['id', 'revision', 'contract', ...(key === 'surface' ? ['records'] : [])])
|
|
74
|
+
if (Object.hasOwn(value, reserved))
|
|
75
|
+
error(`Host owns ${key}.${reserved}; supply content only`);
|
|
76
|
+
const prior = context.previous?.[key];
|
|
77
|
+
value.contract = key === 'research' ? 'n8r.research/0.1' : 'n8r.surface/0.1';
|
|
78
|
+
value.id = prior?.id ?? `urn:n8r:${key}:${randomUUID()}`;
|
|
79
|
+
value.revision = { id: randomUUID(), parent: prior ? revisionRef(prior) : null, createdAt, author: 'n8r-surface Researcher', reason: prior ? 'Human-requested revision' : 'Generated from research' };
|
|
80
|
+
}
|
|
81
|
+
const research = validateArtefact(data.research);
|
|
82
|
+
data.surface.records = [revisionRef(research)];
|
|
83
|
+
for (const entity of [...(data.surface.nodes ?? []), ...(data.surface.relationships ?? [])]) {
|
|
84
|
+
if (!Array.isArray(entity.basis) || !entity.basis.length)
|
|
85
|
+
error('Each entity needs a nonempty structured basis array');
|
|
86
|
+
for (const basis of entity.basis) {
|
|
87
|
+
if (!basis || Object.keys(basis).join(',') !== 'items')
|
|
88
|
+
error('Supply each basis as {items: [finding or decision IDs]}; the host owns record/revision');
|
|
89
|
+
basis.record = research.id;
|
|
90
|
+
basis.revision = research.revision.id;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const surface = validateArtefact(data.surface);
|
|
94
|
+
verifyReferences(surface, [research]);
|
|
95
|
+
const draft = { research, surface };
|
|
96
|
+
if (context.previous)
|
|
97
|
+
verifyPairSuccessor(context.previous, draft);
|
|
98
|
+
return draft;
|
|
99
|
+
}
|
|
100
|
+
export function previewSurface(draft) {
|
|
101
|
+
if (isBundle(draft))
|
|
102
|
+
return renderBrief(draft) + '\nNot saved. Enter /save surface to save exactly this preview.';
|
|
103
|
+
const { research: r, surface: s } = draft;
|
|
104
|
+
return ['Validated partial Surface preview (declared only; not runtime proof)', s.title,
|
|
105
|
+
'Components:', ...s.nodes.map(n => ` ${n.id} | ${n.label}`),
|
|
106
|
+
'Declared connections:', ...s.relationships.map(e => ` ${e.id}: ${e.from} -> ${e.to} (${e.type.term})`),
|
|
107
|
+
'Human decisions:', ...r.decisions.map(d => ` ${d.id}: ${d.choice} ${r.alternatives.find(a => a.id === d.alternative)?.title} — ${d.reason}`),
|
|
108
|
+
'Requirements:', ...s.requirements.map(q => ` ${q.id}: ${q.state} — ${q.description}`),
|
|
109
|
+
'Coverage:', ...s.coverage.map(x => ` ${x}`), 'Unknowns and gaps:', ...s.gaps.map(x => ` ${x}`),
|
|
110
|
+
'Evidence:', ...r.findings.map(f => ` ${f.id}: ${f.state} — ${f.note}; ${f.claims.flatMap(c => c.evidence.map(e => `${e.source} ${e.locator}`)).join('; ')}`),
|
|
111
|
+
'Not saved. Enter /save surface to save exactly this preview.'].join('\n');
|
|
112
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** Content fields mirror the existing experimental contracts; host metadata is omitted. */
|
|
2
|
+
export declare const legacySurfaceInstructions = "\nSTRUCTURED SURFACE CAPABILITY (host adapter 0.1):\nWhen the user asks to create or update a Surface, call surface_draft with research and surface content objects. This stages a preview only. Never claim a save. Natural-language save requests explain /save surface. /save still saves Markdown. A later user turn invalidates the preview, so restage a revised pair after answering a correction. Retain stable component IDs from notes. If an old label is not namespaced, record the explicit old-to-new mapping in research.notes and show it to the human. Do not silently rename identities.\n\nUse these exact content fields (no contract, id, revision or records; the host assigns them):\nresearch: {title,scope,extensions,sources,findings,alternatives,decisions,questions,notes}.\nsurface: {title,scope,extensions,kind:\"definition\",vocabularies,nodes,relationships,requirements,coverage,gaps}.\nAll collections are arrays except extensions and entity attributes, which are objects. Empty arrays/objects are allowed. Nonempty text is required for text fields. IDs use a URI-style namespace such as urn:lumen:app. All timestamps use real UTC seconds, YYYY-MM-DDTHH:mm:ssZ, without fractions. Only safe integer JSON numbers are supported. Do not add undocumented fields.\n\nSource: {id,location,version,digest,inspectedAt,coverage:[{path,status,note}]}. location is a portable relative source path from an ACTUAL source_read/search or saved host evidence, never an absolute path. digest must be the exact SHA-256 from that tool or saved host evidence. Never invent a digest. status is inspected|missing|unreadable|excluded|truncated|unsupported. Record ranges and limits. On revision retain historical source objects exactly and add a new source ID for new bytes or inspection; never relabel old evidence as fresh.\nFinding: {id,subject,property,state,note,claims:[{value,evidence:[{source,locator}]}]}. subject is a component ID; evidence.source references sources[].id. state is known|unknown|omitted|redacted|absent|out-of-scope|conflicting. known/absent require one agreed sourced value; conflicting requires different sourced values; other states require empty claims. Every claim requires evidence. Keep human decisions separate from sourced claims.\nAlternative: {id,title,findings:[finding IDs],requirements:[text descriptions]}.\nDecision: {id,alternative,choice:\"selected\"|\"rejected\"|\"deferred\",reason,by,at}. One current decision per alternative. Use actual human choices, never infer selection from support. Preserve prior choices unless the human changes them. Record an explicit correction's reason and date; previous revisions retain prior choices.\nQuestion: {id,text,findings:[finding IDs]}. Note: {by,at,text}. Keep human prose, unresolved questions and recommended next investigations.\nVocabulary: {id,version}; version is a token such as 0.1. Use an explicitly scoped provisional vocabulary, not a claim of universal compatibility.\nNode: {id,label,type:{vocabulary,term},attributes:{property:{state,note,value?}},basis:[{items:[finding/decision/alternative/question IDs]}],requirements:[requirement IDs]}.\nRelationship: same fields as a node EXCEPT no label, plus {from:node ID,to:node ID,claim:\"declared\"}. Keep endpoint identity for retained relationship IDs. Only selected composition belongs in relationships. A supported option is not automatically selected. Shared components, cycles and parallel edges are allowed.\nFact state is known|unknown|omitted|redacted|absent|out-of-scope. Only known facts have a value; all other states omit value. Put conflicting values in research and unknown in the Surface with an explanatory note.\nRequirement: {id,kind:\"secret\"|\"resource\"|\"configuration\",description,state}. Never include credential values. coverage and gaps are arrays of nonempty strings. Surface basis contains only items; the host fills the exact research record/revision and digest.\n\nOn pair reopening keep research and Surface identities via host bookkeeping. Retain all research sources, findings, alternatives, decisions, questions and component IDs. Keep historical claims and research notes exactly; append corrections and new evidence instead of removing history. Every node and relationship needs nonempty basis linking structured findings/decisions. Retained finding subjects/properties and node types cannot change; retain opaque extensions exactly. Removed selections remain rejected/deferred in research. Preserve prior unknowns unless evidence resolves them. Explain source unavailable/changed status and distinguish declared from reachable or exercised. On validation error, repair the staged content using feedback; do not discard evidence to make validation pass.\n";
|
|
3
|
+
export declare const surfaceInstructions: string;
|