@n8r/surface 0.0.0-stage → 0.1.0-experimental.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +202 -0
  2. package/LICENSE-DOCS +395 -0
  3. package/LICENSING.md +36 -0
  4. package/README.md +181 -2
  5. package/dist/activity.d.ts +16 -0
  6. package/dist/activity.js +44 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +260 -0
  9. package/dist/codec.d.ts +4 -0
  10. package/dist/codec.js +41 -0
  11. package/dist/codex.d.ts +72 -0
  12. package/dist/codex.js +510 -0
  13. package/dist/contracts.d.ts +129 -0
  14. package/dist/contracts.js +1 -0
  15. package/dist/html.d.ts +5 -0
  16. package/dist/html.js +50 -0
  17. package/dist/index.d.ts +7 -0
  18. package/dist/index.js +5 -0
  19. package/dist/notes.d.ts +52 -0
  20. package/dist/notes.js +184 -0
  21. package/dist/portable.d.ts +10 -0
  22. package/dist/portable.js +71 -0
  23. package/dist/revisions.d.ts +2 -0
  24. package/dist/revisions.js +18 -0
  25. package/dist/settings.d.ts +23 -0
  26. package/dist/settings.js +100 -0
  27. package/dist/setup.d.ts +9 -0
  28. package/dist/setup.js +161 -0
  29. package/dist/source-tools.d.ts +102 -0
  30. package/dist/source-tools.js +201 -0
  31. package/dist/startup.d.ts +7 -0
  32. package/dist/startup.js +74 -0
  33. package/dist/surface-draft.d.ts +19 -0
  34. package/dist/surface-draft.js +95 -0
  35. package/dist/surface-instructions.d.ts +2 -0
  36. package/dist/surface-instructions.js +23 -0
  37. package/dist/surface-store.d.ts +36 -0
  38. package/dist/surface-store.js +181 -0
  39. package/dist/validate.d.ts +4 -0
  40. package/dist/validate.js +184 -0
  41. package/docs/n8r-surface/README.md +133 -0
  42. package/docs/n8r-surface/codex-research-transcript.md +51 -0
  43. package/docs/n8r-surface/codex-research-verification.md +176 -0
  44. package/docs/n8r-surface/command-outcomes.md +564 -0
  45. package/docs/n8r-surface/contracts.md +210 -0
  46. package/docs/n8r-surface/critical-path.md +163 -0
  47. package/docs/n8r-surface/design.md +289 -0
  48. package/docs/n8r-surface/getting-started.md +107 -0
  49. package/docs/n8r-surface/host-contract.md +176 -0
  50. package/docs/n8r-surface/implementation-decisions.md +96 -0
  51. package/docs/n8r-surface/npm-distribution.md +137 -0
  52. package/docs/n8r-surface/npm-release.md +188 -0
  53. package/docs/n8r-surface/recreation-handoff.md +117 -0
  54. package/docs/n8r-surface/researcher-discovery.md +157 -0
  55. package/docs/n8r-surface/researcher-prepared-check.md +117 -0
  56. package/docs/n8r-surface/researcher-reconciliation-trial.md +149 -0
  57. package/docs/n8r-surface/researcher-repository-check.md +95 -0
  58. package/docs/n8r-surface/researcher-skeleton-check.md +84 -0
  59. package/docs/n8r-surface/researcher-skill-verification.md +725 -0
  60. package/docs/n8r-surface/researcher-skill.md +255 -0
  61. package/docs/n8r-surface/researcher-vault.md +134 -0
  62. package/docs/n8r-surface/save-reopen-transcript.md +211 -0
  63. package/docs/n8r-surface/save-reopen-verification.md +152 -0
  64. package/docs/n8r-surface/startup-human-check.md +172 -0
  65. package/docs/n8r-surface/startup-verification.md +161 -0
  66. package/docs/n8r-surface/startup.md +151 -0
  67. package/docs/n8r-surface/surface-revisions-transcript.md +552 -0
  68. package/docs/n8r-surface/surface-revisions-verification.md +161 -0
  69. package/docs/n8r-surface/vault-setup-verification.md +210 -0
  70. package/docs/n8r-surface/verification.md +117 -0
  71. package/docs/n8r-surface/versioning.md +183 -0
  72. package/examples/researcher/README.md +92 -0
  73. package/examples/researcher/composed.surface.json +430 -0
  74. package/examples/researcher/composed.surface.yaml +283 -0
  75. package/examples/researcher/harbor-r1.research.html +193 -0
  76. package/examples/researcher/harbor-r2.research.html +226 -0
  77. package/examples/researcher/lumen-manual-edit.pending.html +271 -0
  78. package/examples/researcher/lumen-r1.research.html +271 -0
  79. package/examples/researcher/lumen-r2.research.html +280 -0
  80. package/examples/researcher/sources/harbor-api.md +16 -0
  81. package/examples/researcher/sources/harbor-auth.md +8 -0
  82. package/examples/researcher/sources/lumen-guide.md +28 -0
  83. package/examples/researcher/sources/lumen-settings.md +9 -0
  84. package/package.json +45 -4
  85. package/plugins/n8r-surface/.codex-plugin/plugin.json +31 -0
  86. package/plugins/n8r-surface/LICENSE +202 -0
  87. package/plugins/n8r-surface/LICENSE-DOCS +395 -0
  88. package/plugins/n8r-surface/LICENSING.md +36 -0
  89. package/plugins/n8r-surface/assets/icon.svg +5 -0
  90. package/plugins/n8r-surface/plugin.json +27 -0
  91. package/plugins/n8r-surface/skills/research/SKILL.md +122 -0
  92. package/plugins/n8r-surface/skills/research/agents/openai.yaml +4 -0
  93. package/plugins/n8r-surface/skills/research/references/discovery-map.md +160 -0
  94. package/plugins/n8r-surface/skills/research/references/model-meanings.md +55 -0
  95. package/plugins/n8r-surface/skills/research/references/skeleton-surface.md +105 -0
  96. package/plugins/n8r-surface/skills/research/references/working-notes.md +116 -0
package/dist/setup.js ADDED
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs';
2
+ import { dirname, join, relative, resolve, sep } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { InspectionError, loadSettings, localPath } from './settings.js';
5
+ const children = ['research', 'artifacts', 'surfaces'];
6
+ /** Resolve existing ancestors as well as explicitly chosen root symlinks. */
7
+ function canonical(path) {
8
+ try {
9
+ return fs.realpathSync(path);
10
+ }
11
+ catch (error) {
12
+ if (error.code !== 'ENOENT')
13
+ throw error;
14
+ // Reject dangling symlinks rather than treating them as new directories.
15
+ try {
16
+ fs.lstatSync(path);
17
+ throw new InspectionError('Dangling symlink');
18
+ }
19
+ catch (entryError) {
20
+ if (entryError.code !== 'ENOENT')
21
+ throw entryError;
22
+ }
23
+ const parent = dirname(path);
24
+ if (parent === path)
25
+ throw error;
26
+ return join(canonical(parent), path.slice(parent.length));
27
+ }
28
+ }
29
+ export function chooseVault(value, home, cwd) {
30
+ return localPath(canonical(localPath(value, home, cwd)), home, cwd);
31
+ }
32
+ /** Read-only validation: missing folders need consent, occupied paths are errors. */
33
+ export function inspectVault(root) {
34
+ root = canonical(root);
35
+ let missing = false;
36
+ for (const path of [root, ...children.map(child => join(root, child))]) {
37
+ try {
38
+ const stat = fs.lstatSync(path);
39
+ if (stat.isSymbolicLink())
40
+ throw new InspectionError('Vault children cannot be symlinks');
41
+ if (!stat.isDirectory())
42
+ throw new InspectionError('Vault path is not a directory');
43
+ fs.accessSync(path, fs.constants.R_OK | fs.constants.X_OK);
44
+ }
45
+ catch (error) {
46
+ if (error.code !== 'ENOENT')
47
+ throw error;
48
+ missing = true;
49
+ }
50
+ }
51
+ return { root, missing };
52
+ }
53
+ function inside(path, source) {
54
+ const part = relative(source, canonical(path));
55
+ return part === '' || (part !== '..' && !part.startsWith(`..${sep}`) && !part.startsWith(sep));
56
+ }
57
+ function unchanged(snapshot) {
58
+ const current = loadSettings(snapshot.home).original;
59
+ if (snapshot.original === undefined ? current !== undefined : current === undefined || !snapshot.original.equals(current)) {
60
+ throw new InspectionError('Settings changed; setup conflict. Restart and review the saved values.');
61
+ }
62
+ }
63
+ /** Called only after explicit acceptance. See startup.md for the writer boundary. */
64
+ export function saveSetup(snapshot, selected, cwd) {
65
+ const vault = inspectVault(chooseVault(selected, snapshot.home, cwd)).root;
66
+ const source = canonical(resolve(cwd));
67
+ const file = snapshot.file;
68
+ for (const path of [dirname(file), file, vault, ...children.map(child => join(vault, child))]) {
69
+ if (inside(path, source))
70
+ throw new InspectionError('Setup would write inside the source; choose a separate vault or source directory.');
71
+ }
72
+ if (snapshot.vault !== undefined && canonical(snapshot.vault) !== vault)
73
+ throw new InspectionError('Configured vault cannot be changed by setup');
74
+ const legacy = canonical(snapshot.root);
75
+ for (const child of children) {
76
+ const path = join(vault, child);
77
+ if (canonical(path) !== legacy && inside(path, legacy)) {
78
+ throw new InspectionError('Vault folders would occupy the legacy artefact corpus; choose a separate vault.');
79
+ }
80
+ }
81
+ // Keep unknown JSON member bytes, including numbers outside JS precision/range.
82
+ // The validated original is an object containing version, so a comma is valid.
83
+ let output = Buffer.alloc(0);
84
+ if (snapshot.vault === undefined) {
85
+ if (snapshot.original === undefined) {
86
+ output = Buffer.from(JSON.stringify({ version: 1, vaultRoot: vault }, null, 2) + '\n');
87
+ }
88
+ else {
89
+ const end = snapshot.original.lastIndexOf(0x7d);
90
+ output = Buffer.concat([snapshot.original.subarray(0, end),
91
+ Buffer.from(`,\n "vaultRoot": ${JSON.stringify(vault)}\n`), snapshot.original.subarray(end)]);
92
+ }
93
+ }
94
+ if (output.length > 64 * 1024)
95
+ throw new InspectionError('Updated settings exceeds 65536 bytes');
96
+ fs.mkdirSync(dirname(file), { recursive: true });
97
+ const lock = file + '.lock';
98
+ let lockFd;
99
+ try {
100
+ lockFd = fs.openSync(lock, 'wx', 0o600);
101
+ }
102
+ catch (error) {
103
+ if (error.code === 'EEXIST')
104
+ throw new InspectionError('Setup lock exists; another setup may be in progress. See startup documentation for stale-lock recovery.');
105
+ throw error;
106
+ }
107
+ let temporary;
108
+ try {
109
+ unchanged(snapshot);
110
+ fs.mkdirSync(vault, { recursive: true });
111
+ for (const child of children) {
112
+ const path = join(vault, child);
113
+ try {
114
+ fs.mkdirSync(path);
115
+ }
116
+ catch (error) {
117
+ if (error.code !== 'EEXIST')
118
+ throw error;
119
+ }
120
+ const stat = fs.lstatSync(path);
121
+ if (!stat.isDirectory() || stat.isSymbolicLink())
122
+ throw new InspectionError('Vault child is occupied or a symlink');
123
+ }
124
+ inspectVault(vault);
125
+ unchanged(snapshot);
126
+ if (snapshot.vault === undefined) {
127
+ const candidate = file + '.' + randomUUID() + '.tmp';
128
+ const fd = fs.openSync(candidate, 'wx', 0o600);
129
+ temporary = candidate;
130
+ try {
131
+ fs.writeFileSync(fd, output);
132
+ }
133
+ finally {
134
+ fs.closeSync(fd);
135
+ }
136
+ unchanged(snapshot);
137
+ if (snapshot.original === undefined)
138
+ fs.linkSync(temporary, file);
139
+ else
140
+ fs.renameSync(temporary, file);
141
+ }
142
+ return vault;
143
+ }
144
+ finally {
145
+ try {
146
+ if (temporary !== undefined) {
147
+ try {
148
+ fs.unlinkSync(temporary);
149
+ }
150
+ catch (error) {
151
+ if (error.code !== 'ENOENT')
152
+ throw error;
153
+ }
154
+ }
155
+ }
156
+ finally {
157
+ fs.closeSync(lockFd);
158
+ fs.unlinkSync(lock);
159
+ }
160
+ }
161
+ }
@@ -0,0 +1,102 @@
1
+ type Entry = {
2
+ path: string;
3
+ kind: 'file' | 'directory';
4
+ };
5
+ type Omission = {
6
+ path: string;
7
+ reason: string;
8
+ };
9
+ /** Local read capability. Directory topology must remain stable during an operation. */
10
+ export declare class SourceTools {
11
+ readonly root: string;
12
+ readonly file: string | undefined;
13
+ private identity;
14
+ constructor(source: string);
15
+ get selected(): string;
16
+ private checkRoot;
17
+ private path;
18
+ list(path?: string): {
19
+ entries: Entry[];
20
+ excluded: Omission[];
21
+ truncated: boolean;
22
+ };
23
+ private bytes;
24
+ read(path: string, startLine?: number, lineCount?: number): {
25
+ path: string;
26
+ digest: string;
27
+ inspectedAt: string;
28
+ startLine: number;
29
+ endLine: number;
30
+ totalLines: number;
31
+ text: string;
32
+ truncated: boolean;
33
+ };
34
+ search(query: string): {
35
+ query: string;
36
+ matches: {
37
+ path: string;
38
+ line: number;
39
+ text: string;
40
+ digest: string;
41
+ }[];
42
+ excluded: Omission[];
43
+ truncated: boolean;
44
+ filesInspected: number;
45
+ inspectedAt: string;
46
+ };
47
+ call(tool: string, args: unknown): unknown;
48
+ }
49
+ export declare const sourceToolSpecs: ({
50
+ name: string;
51
+ description: string;
52
+ inputSchema: {
53
+ type: string;
54
+ properties: {
55
+ path: {
56
+ type: string;
57
+ };
58
+ startLine?: undefined;
59
+ lineCount?: undefined;
60
+ query?: undefined;
61
+ };
62
+ required: string[];
63
+ additionalProperties: boolean;
64
+ };
65
+ } | {
66
+ name: string;
67
+ description: string;
68
+ inputSchema: {
69
+ type: string;
70
+ properties: {
71
+ path: {
72
+ type: string;
73
+ };
74
+ startLine: {
75
+ type: string;
76
+ };
77
+ lineCount: {
78
+ type: string;
79
+ };
80
+ query?: undefined;
81
+ };
82
+ required: string[];
83
+ additionalProperties: boolean;
84
+ };
85
+ } | {
86
+ name: string;
87
+ description: string;
88
+ inputSchema: {
89
+ type: string;
90
+ properties: {
91
+ query: {
92
+ type: string;
93
+ };
94
+ path?: undefined;
95
+ startLine?: undefined;
96
+ lineCount?: undefined;
97
+ };
98
+ required: string[];
99
+ additionalProperties: boolean;
100
+ };
101
+ })[];
102
+ export {};
@@ -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
+ ];
@@ -0,0 +1,7 @@
1
+ import { type Settings } from './settings.js';
2
+ export declare function overview(): {
3
+ text: string;
4
+ failed: boolean;
5
+ configured: boolean;
6
+ settings?: Settings;
7
+ };
@@ -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,19 @@
1
+ import type { ResearchRecord, SurfaceDefinition, Artefact } from './contracts.js';
2
+ export type SurfaceDraft = {
3
+ research: ResearchRecord;
4
+ surface: SurfaceDefinition;
5
+ };
6
+ export type DraftContext = {
7
+ createdAt?: string;
8
+ previous?: SurfaceDraft;
9
+ };
10
+ export declare const revisionRef: (a: Artefact) => {
11
+ id: string;
12
+ revision: string;
13
+ digest: string;
14
+ };
15
+ /** Preservation beyond the library's bounded lineage checks. */
16
+ export declare function verifyPairSuccessor(before: SurfaceDraft, after: SurfaceDraft): void;
17
+ /** The model supplies contract content; the host owns envelope and record-reference fields. */
18
+ export declare function prepareSurfaceDraft(input: unknown, context?: DraftContext): SurfaceDraft;
19
+ export declare function previewSurface({ research: r, surface: s }: SurfaceDraft): string;
@@ -0,0 +1,95 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { canonical, digest } from './portable.js';
3
+ import { validateArtefact, verifyReferences } from './validate.js';
4
+ import { verifySuccessor } from './revisions.js';
5
+ import { InspectionError } from './settings.js';
6
+ export const revisionRef = (a) => ({ id: a.id, revision: a.revision.id, digest: digest(a) });
7
+ const error = (s) => { throw new InspectionError(s); };
8
+ function retained(before, after, label) {
9
+ for (const old of before)
10
+ if (!after.some(x => x.id === old.id))
11
+ error(`Preserve ${label} identity ${old.id}; deletion is outside this increment`);
12
+ }
13
+ /** Preservation beyond the library's bounded lineage checks. */
14
+ export function verifyPairSuccessor(before, after) {
15
+ verifySuccessor(before.research, after.research);
16
+ verifySuccessor(before.surface, after.surface);
17
+ for (const key of ['sources', 'findings', 'alternatives', 'decisions', 'questions'])
18
+ retained(before.research[key], after.research[key], key);
19
+ retained(before.surface.nodes, after.surface.nodes, 'component');
20
+ for (const source of before.research.sources) {
21
+ if (canonical(source) !== canonical(after.research.sources.find(x => x.id === source.id)))
22
+ error(`Preserve historical source ${source.id}; add a new source identity for new evidence`);
23
+ }
24
+ for (const finding of before.research.findings) {
25
+ const next = after.research.findings.find(x => x.id === finding.id);
26
+ for (const claim of finding.claims)
27
+ if (!next.claims.some(c => canonical(c) === canonical(claim)))
28
+ error(`Preserve historical claim in ${finding.id}`);
29
+ if (next.subject !== finding.subject || next.property !== finding.property)
30
+ error(`Retained finding changed subject/property: ${finding.id}`);
31
+ }
32
+ for (const note of before.research.notes)
33
+ if (!after.research.notes.some(n => canonical(n) === canonical(note)))
34
+ error('Preserve historical research note; append corrections');
35
+ for (const decision of before.research.decisions)
36
+ if (after.research.decisions.find(x => x.id === decision.id).alternative !== decision.alternative)
37
+ error(`Retained decision changed alternative: ${decision.id}`);
38
+ for (const node of before.surface.nodes)
39
+ if (canonical(after.surface.nodes.find(x => x.id === node.id).type) !== canonical(node.type))
40
+ error(`Retained component changed type: ${node.id}`);
41
+ for (const key of ['research', 'surface'])
42
+ for (const [name, value] of Object.entries(before[key].extensions)) {
43
+ if (!Object.hasOwn(after[key].extensions, name) || canonical(value) !== canonical(after[key].extensions[name]))
44
+ error(`Preserve opaque extension ${name}`);
45
+ }
46
+ }
47
+ /** The model supplies contract content; the host owns envelope and record-reference fields. */
48
+ export function prepareSurfaceDraft(input, context = {}) {
49
+ canonical(input); // bound size/depth and reject non-data before inspecting it
50
+ const raw = input;
51
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || Object.keys(raw).sort().join(',') !== 'research,surface')
52
+ error('Expected research and surface content objects');
53
+ const data = JSON.parse(JSON.stringify(raw));
54
+ const createdAt = context.createdAt ?? new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
55
+ for (const key of ['research', 'surface']) {
56
+ const value = data[key];
57
+ if (!value || typeof value !== 'object' || Array.isArray(value))
58
+ error(`Invalid ${key} content`);
59
+ for (const reserved of ['id', 'revision', 'contract', ...(key === 'surface' ? ['records'] : [])])
60
+ if (Object.hasOwn(value, reserved))
61
+ error(`Host owns ${key}.${reserved}; supply content only`);
62
+ const prior = context.previous?.[key];
63
+ value.contract = key === 'research' ? 'n8r.research/0.1' : 'n8r.surface/0.1';
64
+ value.id = prior?.id ?? `urn:n8r:${key}:${randomUUID()}`;
65
+ value.revision = { id: randomUUID(), parent: prior ? revisionRef(prior) : null, createdAt, author: 'n8r-surface Researcher', reason: prior ? 'Human-requested revision' : 'Generated from research' };
66
+ }
67
+ const research = validateArtefact(data.research);
68
+ data.surface.records = [revisionRef(research)];
69
+ for (const entity of [...(data.surface.nodes ?? []), ...(data.surface.relationships ?? [])]) {
70
+ if (!Array.isArray(entity.basis) || !entity.basis.length)
71
+ error('Each entity needs a nonempty structured basis array');
72
+ for (const basis of entity.basis) {
73
+ if (!basis || Object.keys(basis).join(',') !== 'items')
74
+ error('Supply each basis as {items: [finding or decision IDs]}; the host owns record/revision');
75
+ basis.record = research.id;
76
+ basis.revision = research.revision.id;
77
+ }
78
+ }
79
+ const surface = validateArtefact(data.surface);
80
+ verifyReferences(surface, [research]);
81
+ const draft = { research, surface };
82
+ if (context.previous)
83
+ verifyPairSuccessor(context.previous, draft);
84
+ return draft;
85
+ }
86
+ export function previewSurface({ research: r, surface: s }) {
87
+ return ['Validated partial Surface preview (declared only; not runtime proof)', s.title,
88
+ 'Components:', ...s.nodes.map(n => ` ${n.id} | ${n.label}`),
89
+ 'Declared connections:', ...s.relationships.map(e => ` ${e.id}: ${e.from} -> ${e.to} (${e.type.term})`),
90
+ 'Human decisions:', ...r.decisions.map(d => ` ${d.id}: ${d.choice} ${r.alternatives.find(a => a.id === d.alternative)?.title} — ${d.reason}`),
91
+ 'Requirements:', ...s.requirements.map(q => ` ${q.id}: ${q.state} — ${q.description}`),
92
+ 'Coverage:', ...s.coverage.map(x => ` ${x}`), 'Unknowns and gaps:', ...s.gaps.map(x => ` ${x}`),
93
+ 'Evidence:', ...r.findings.map(f => ` ${f.id}: ${f.state} — ${f.note}; ${f.claims.flatMap(c => c.evidence.map(e => `${e.source} ${e.locator}`)).join('; ')}`),
94
+ 'Not saved. Enter /save surface to save exactly this preview.'].join('\n');
95
+ }