@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.
Files changed (181) hide show
  1. package/LICENSE +202 -0
  2. package/LICENSE-DOCS +395 -0
  3. package/LICENSING.md +36 -0
  4. package/README.md +207 -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 +412 -0
  9. package/dist/codec.d.ts +6 -0
  10. package/dist/codec.js +56 -0
  11. package/dist/codex.d.ts +72 -0
  12. package/dist/codex.js +526 -0
  13. package/dist/contracts.d.ts +235 -0
  14. package/dist/contracts.js +1 -0
  15. package/dist/cyclonedx-schemas/bom-1.7.schema.json +6700 -0
  16. package/dist/cyclonedx-schemas/cryptography-defs.schema.json +576 -0
  17. package/dist/cyclonedx-schemas/jsf-0.82.schema.json +240 -0
  18. package/dist/cyclonedx-schemas/spdx.schema.json +786 -0
  19. package/dist/cyclonedx-validation.d.ts +1 -0
  20. package/dist/cyclonedx-validation.js +45 -0
  21. package/dist/html.d.ts +5 -0
  22. package/dist/html.js +74 -0
  23. package/dist/index.d.ts +7 -0
  24. package/dist/index.js +5 -0
  25. package/dist/notes.d.ts +52 -0
  26. package/dist/notes.js +184 -0
  27. package/dist/portable.d.ts +10 -0
  28. package/dist/portable.js +71 -0
  29. package/dist/recent.d.ts +9 -0
  30. package/dist/recent.js +71 -0
  31. package/dist/revisions.d.ts +2 -0
  32. package/dist/revisions.js +60 -0
  33. package/dist/settings.d.ts +23 -0
  34. package/dist/settings.js +100 -0
  35. package/dist/setup.d.ts +9 -0
  36. package/dist/setup.js +161 -0
  37. package/dist/skill-artifacts-cli.d.ts +1 -0
  38. package/dist/skill-artifacts-cli.js +13 -0
  39. package/dist/skill-artifacts.d.ts +15 -0
  40. package/dist/skill-artifacts.js +184 -0
  41. package/dist/skill-cyclonedx.d.ts +26 -0
  42. package/dist/skill-cyclonedx.js +214 -0
  43. package/dist/skill-presentation.d.ts +10 -0
  44. package/dist/skill-presentation.js +108 -0
  45. package/dist/skill-surface-cli.d.ts +1 -0
  46. package/dist/skill-surface-cli.js +37 -0
  47. package/dist/skill-surface-store.d.ts +7 -0
  48. package/dist/skill-surface-store.js +126 -0
  49. package/dist/source-tools.d.ts +102 -0
  50. package/dist/source-tools.js +201 -0
  51. package/dist/startup.d.ts +7 -0
  52. package/dist/startup.js +74 -0
  53. package/dist/surface-bom.d.ts +73 -0
  54. package/dist/surface-bom.js +139 -0
  55. package/dist/surface-draft.d.ts +22 -0
  56. package/dist/surface-draft.js +112 -0
  57. package/dist/surface-instructions.d.ts +3 -0
  58. package/dist/surface-instructions.js +32 -0
  59. package/dist/surface-overview.d.ts +15 -0
  60. package/dist/surface-overview.js +86 -0
  61. package/dist/surface-store.d.ts +34 -0
  62. package/dist/surface-store.js +213 -0
  63. package/dist/surface-view.d.ts +3 -0
  64. package/dist/surface-view.js +50 -0
  65. package/dist/validate.d.ts +4 -0
  66. package/dist/validate.js +482 -0
  67. package/docs/n8r-surface/README.md +133 -0
  68. package/docs/n8r-surface/cli-bundles.md +72 -0
  69. package/docs/n8r-surface/codex-research-transcript.md +51 -0
  70. package/docs/n8r-surface/codex-research-verification.md +176 -0
  71. package/docs/n8r-surface/command-outcomes.md +711 -0
  72. package/docs/n8r-surface/contracts.md +220 -0
  73. package/docs/n8r-surface/critical-path.md +184 -0
  74. package/docs/n8r-surface/design.md +289 -0
  75. package/docs/n8r-surface/first-use-access.md +143 -0
  76. package/docs/n8r-surface/first-use-overview.md +262 -0
  77. package/docs/n8r-surface/getting-started.md +74 -0
  78. package/docs/n8r-surface/guided-journey-verification.md +137 -0
  79. package/docs/n8r-surface/host-contract.md +176 -0
  80. package/docs/n8r-surface/implementation-decisions.md +96 -0
  81. package/docs/n8r-surface/instance-first-research.md +209 -0
  82. package/docs/n8r-surface/npm-distribution.md +107 -0
  83. package/docs/n8r-surface/npm-release.md +315 -0
  84. package/docs/n8r-surface/product-direction.md +327 -0
  85. package/docs/n8r-surface/recreation-handoff.md +117 -0
  86. package/docs/n8r-surface/researcher-discovery.md +157 -0
  87. package/docs/n8r-surface/researcher-prepared-check.md +117 -0
  88. package/docs/n8r-surface/researcher-reconciliation-trial.md +149 -0
  89. package/docs/n8r-surface/researcher-repository-check.md +95 -0
  90. package/docs/n8r-surface/researcher-skeleton-check.md +84 -0
  91. package/docs/n8r-surface/researcher-skill-verification.md +725 -0
  92. package/docs/n8r-surface/researcher-skill.md +309 -0
  93. package/docs/n8r-surface/researcher-vault.md +134 -0
  94. package/docs/n8r-surface/save-reopen-transcript.md +211 -0
  95. package/docs/n8r-surface/save-reopen-verification.md +152 -0
  96. package/docs/n8r-surface/security-assurance-strategy.md +267 -0
  97. package/docs/n8r-surface/skill-bom-verification.md +142 -0
  98. package/docs/n8r-surface/skill-cyclonedx-verification.md +144 -0
  99. package/docs/n8r-surface/skill-cyclonedx.md +85 -0
  100. package/docs/n8r-surface/skill-manual.md +129 -0
  101. package/docs/n8r-surface/skill-surface-artifacts.md +234 -0
  102. package/docs/n8r-surface/skill-usefulness-verification.md +135 -0
  103. package/docs/n8r-surface/skill-workspace-automation.md +359 -0
  104. package/docs/n8r-surface/startup-human-check.md +172 -0
  105. package/docs/n8r-surface/startup-verification.md +161 -0
  106. package/docs/n8r-surface/startup.md +151 -0
  107. package/docs/n8r-surface/surface-description-contract.md +224 -0
  108. package/docs/n8r-surface/surface-descriptions.md +173 -0
  109. package/docs/n8r-surface/surface-relationships.md +231 -0
  110. package/docs/n8r-surface/surface-revisions-transcript.md +552 -0
  111. package/docs/n8r-surface/surface-revisions-verification.md +161 -0
  112. package/docs/n8r-surface/tester-candidate.md +158 -0
  113. package/docs/n8r-surface/vault-setup-verification.md +210 -0
  114. package/docs/n8r-surface/verification.md +117 -0
  115. package/docs/n8r-surface/versioning.md +172 -0
  116. package/docs/n8r-surface/workspace-human-check.md +101 -0
  117. package/docs/n8r-surface/workspace-installation.md +68 -0
  118. package/docs/n8r-surface/workspace-verification.md +350 -0
  119. package/docs/n8r-surface/workspace.md +83 -0
  120. package/examples/researcher/README.md +100 -0
  121. package/examples/researcher/composed.surface.json +430 -0
  122. package/examples/researcher/composed.surface.yaml +283 -0
  123. package/examples/researcher/cyclonedx/configured/README.md +7 -0
  124. package/examples/researcher/cyclonedx/configured/manifest.json +10 -0
  125. package/examples/researcher/cyclonedx/configured/mapping.json +323 -0
  126. package/examples/researcher/cyclonedx/configured/surface-records.json +1275 -0
  127. package/examples/researcher/cyclonedx/configured/surface.cdx.json +79 -0
  128. package/examples/researcher/descriptions/README.md +29 -0
  129. package/examples/researcher/descriptions/bound-r1.json +141 -0
  130. package/examples/researcher/descriptions/bound-r2.json +153 -0
  131. package/examples/researcher/descriptions/capability.json +167 -0
  132. package/examples/researcher/descriptions/research-r1.json +153 -0
  133. package/examples/researcher/descriptions/research-r2.json +157 -0
  134. package/examples/researcher/descriptions/sources.md +24 -0
  135. package/examples/researcher/harbor-r1.research.html +193 -0
  136. package/examples/researcher/harbor-r2.research.html +226 -0
  137. package/examples/researcher/lumen-manual-edit.pending.html +271 -0
  138. package/examples/researcher/lumen-r1.research.html +271 -0
  139. package/examples/researcher/lumen-r2.research.html +280 -0
  140. package/examples/researcher/relationships/README.md +34 -0
  141. package/examples/researcher/relationships/bound-r1.json +346 -0
  142. package/examples/researcher/relationships/bound-r2.json +358 -0
  143. package/examples/researcher/relationships/capability.json +325 -0
  144. package/examples/researcher/relationships/config/assistant.env.example +4 -0
  145. package/examples/researcher/relationships/research-r1.json +300 -0
  146. package/examples/researcher/relationships/research-r2.json +304 -0
  147. package/examples/researcher/relationships/sources.md +38 -0
  148. package/examples/researcher/sources/harbor-api.md +16 -0
  149. package/examples/researcher/sources/harbor-auth.md +8 -0
  150. package/examples/researcher/sources/lumen-guide.md +28 -0
  151. package/examples/researcher/sources/lumen-settings.md +9 -0
  152. package/examples/researcher/surface-bom/README.md +17 -0
  153. package/examples/researcher/surface-bom/initial.cdx.json +188 -0
  154. package/package.json +45 -4
  155. package/plugins/n8r-surface/.codex-plugin/plugin.json +31 -0
  156. package/plugins/n8r-surface/LICENSE +202 -0
  157. package/plugins/n8r-surface/LICENSE-DOCS +395 -0
  158. package/plugins/n8r-surface/LICENSING.md +39 -0
  159. package/plugins/n8r-surface/assets/icon.svg +5 -0
  160. package/plugins/n8r-surface/plugin.json +27 -0
  161. package/plugins/n8r-surface/skills/research/SKILL.md +163 -0
  162. package/plugins/n8r-surface/skills/research/agents/openai.yaml +4 -0
  163. package/plugins/n8r-surface/skills/research/references/artifacts.md +152 -0
  164. package/plugins/n8r-surface/skills/research/references/cyclonedx.md +207 -0
  165. package/plugins/n8r-surface/skills/research/references/discovery-map.md +178 -0
  166. package/plugins/n8r-surface/skills/research/references/examples/bound-r1.json +346 -0
  167. package/plugins/n8r-surface/skills/research/references/examples/capability.json +325 -0
  168. package/plugins/n8r-surface/skills/research/references/examples/research-r1.json +300 -0
  169. package/plugins/n8r-surface/skills/research/references/examples/surface.cdx.json +188 -0
  170. package/plugins/n8r-surface/skills/research/references/first-use-access.md +97 -0
  171. package/plugins/n8r-surface/skills/research/references/first-use-overview.md +101 -0
  172. package/plugins/n8r-surface/skills/research/references/instance-first.md +125 -0
  173. package/plugins/n8r-surface/skills/research/references/model-meanings.md +55 -0
  174. package/plugins/n8r-surface/skills/research/references/skeleton-surface.md +105 -0
  175. package/plugins/n8r-surface/skills/research/references/working-notes.md +121 -0
  176. package/plugins/n8r-surface/skills/research/references/workspace.md +89 -0
  177. package/plugins/n8r-surface/skills/research/scripts/THIRD-PARTY-NOTICES.txt +273 -0
  178. package/plugins/n8r-surface/skills/research/scripts/artifacts.mjs +8348 -0
  179. package/plugins/n8r-surface/skills/research/scripts/cyclonedx.mjs +28113 -0
  180. package/plugins/n8r-surface/skills/research/scripts/surface.mjs +27800 -0
  181. package/plugins/n8r-surface/skills/research/scripts/workspace.mjs +120 -0
@@ -0,0 +1 @@
1
+ export declare function validateBom(bom: unknown): void;
@@ -0,0 +1,45 @@
1
+ /** Standard validation shared by the legacy reader and BOM authority. */
2
+ import { Ajv } from 'ajv';
3
+ import formats from 'ajv-formats';
4
+ import extendedFormats from 'ajv-formats-draft2019';
5
+ import bomSchema from './cyclonedx-schemas/bom-1.7.schema.json' with { type: 'json' };
6
+ import cryptoSchema from './cyclonedx-schemas/cryptography-defs.schema.json' with { type: 'json' };
7
+ import spdxSchema from './cyclonedx-schemas/spdx.schema.json' with { type: 'json' };
8
+ import jsfSchema from './cyclonedx-schemas/jsf-0.82.schema.json' with { type: 'json' };
9
+ function refuse() { throw Error('CycloneDX 1.7 schema/reference validation failed. Input values withheld.'); }
10
+ function guard(fn) { try {
11
+ return fn();
12
+ }
13
+ catch {
14
+ return refuse();
15
+ } }
16
+ const ajv = new Ajv({ strict: false, allErrors: false, validateFormats: true });
17
+ // ajv-formats CJS interop is normalized by Node and the standalone bundler.
18
+ formats(ajv);
19
+ extendedFormats(ajv);
20
+ // The upstream IRI checker rejects URNs because uri-js parses their NSS
21
+ // separately from path. Accept only the standard BOM-Link grammar here.
22
+ const iriReference = ajv.formats['iri-reference'];
23
+ ajv.addFormat('iri-reference', (value) => /^urn:cdx:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[1-9][0-9]*(?:#[^\s]+)?$/.test(value) || iriReference(value));
24
+ ajv.addSchema(spdxSchema);
25
+ ajv.addSchema(jsfSchema);
26
+ ajv.addSchema(cryptoSchema);
27
+ const schemaCheck = ajv.compile(bomSchema);
28
+ export function validateBom(bom) {
29
+ guard(() => {
30
+ if (!schemaCheck(bom))
31
+ refuse();
32
+ const b = bom;
33
+ if (b.bomFormat !== 'CycloneDX' || b.specVersion !== '1.7')
34
+ refuse();
35
+ const refs = new Set();
36
+ for (const n of [...(b.components ?? []), ...(b.services ?? [])]) {
37
+ if (typeof n['bom-ref'] !== 'string' || refs.has(n['bom-ref']))
38
+ refuse();
39
+ refs.add(n['bom-ref']);
40
+ }
41
+ for (const d of b.dependencies ?? [])
42
+ if (!refs.has(d.ref) || d.dependsOn?.some((r) => !refs.has(r)))
43
+ refuse();
44
+ });
45
+ }
package/dist/html.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { ResearchRecord } from './contracts.js';
2
+ /** The template is part of profile 0.1. Changing it requires a versioned reader. */
3
+ export declare function renderResearchHtml(input: unknown): string;
4
+ /** Extract only to verify; no DOM execution and no permissive HTML repair. */
5
+ export declare function readResearchHtml(html: string): ResearchRecord;
package/dist/html.js ADDED
@@ -0,0 +1,74 @@
1
+ import { decode, encode, decodeJsonValue } from './codec.js';
2
+ import { checkSize, fail, canonical } from './portable.js';
3
+ import { validateArtefact, verifyReferences } from './validate.js';
4
+ import { renderBundleHtml } from './skill-presentation.js';
5
+ const escape = (text) => text.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
6
+ function display(value) {
7
+ if (Array.isArray(value))
8
+ return value.length ? `<ol>${value.map(item => `<li>${display(item)}</li>`).join('')}</ol>` : '<p class="empty">No entries recorded.</p>';
9
+ if (value !== null && typeof value === 'object')
10
+ return `<dl>${Object.entries(value).map(([key, item]) => `<dt>${escape(key)}</dt><dd>${display(item)}</dd>`).join('')}</dl>`;
11
+ return `<span>${escape(typeof value === 'string' ? value : JSON.stringify(value))}</span>`;
12
+ }
13
+ /** The template is part of profile 0.1. Changing it requires a versioned reader. */
14
+ export function renderResearchHtml(input) {
15
+ const record = validateArtefact(input);
16
+ if (record.contract !== 'n8r.research/0.1')
17
+ fail('$', 'HTML requires a research record');
18
+ const data = encode(record, 'json').trimEnd().replace(/[<>&\u2028\u2029]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
19
+ const sections = Object.entries(record).filter(([key]) => key !== 'title').map(([key, value]) => `<section><h2>${escape(key)}</h2>${display(value)}</section>`).join('\n');
20
+ const html = `<!doctype html>
21
+ <html lang="en">
22
+ <head>
23
+ <meta charset="utf-8">
24
+ <meta name="viewport" content="width=device-width, initial-scale=1">
25
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
26
+ <title>${escape(record.title)}</title>
27
+ <style>body{font:17px/1.55 system-ui,sans-serif;max-width:72rem;margin:3rem auto;padding:0 1.5rem;color:#18252d;background:#faf9f5}h1{line-height:1.2}h2{font-size:1.2rem;border-bottom:1px solid #b8c8c9;padding-bottom:.3rem}section{margin:2rem 0}dt{font-weight:650;color:#234c54}dd{margin:0 0 .6rem 1.3rem}li{margin:.75rem 0}span{white-space:pre-wrap;overflow-wrap:anywhere}.empty{color:#56656c;font-style:italic}header p{max-width:55rem}</style>
28
+ </head>
29
+ <body>
30
+ <header><p>Research record · experimental 0.1</p><h1>${escape(record.title)}</h1><p>Source research describes possibilities. It does not establish a running integration. The embedded data owns the facts; visible edits require review before continuation.</p></header>
31
+ ${sections}
32
+ <script type="application/json" id="n8r-record">${data}</script>
33
+ </body>
34
+ </html>
35
+ `;
36
+ checkSize(html);
37
+ return html;
38
+ }
39
+ /** Extract only to verify; no DOM execution and no permissive HTML repair. */
40
+ export function readResearchHtml(html) {
41
+ checkSize(html);
42
+ const blocks = [...html.matchAll(/<script type="application\/json" id="n8r-record">([\s\S]*?)<\/script>/g)];
43
+ if (blocks.length !== 1)
44
+ fail('$html', 'requires exactly one research data block');
45
+ const record = decode(blocks[0][1], 'json');
46
+ if (record.contract !== 'n8r.research/0.1')
47
+ fail('$html', 'requires research record');
48
+ if (html.includes('<meta name="n8r-presentation" content="1">')) {
49
+ const contexts = [...html.matchAll(/<script type="application\/json" id="n8r-context">([\s\S]*?)<\/script>/g)];
50
+ if (contexts.length !== 1)
51
+ fail('$html', 'requires exactly one presentation context');
52
+ const context = decodeJsonValue(contexts[0][1]);
53
+ if (!context || Object.keys(context).sort().join(',') !== 'dependencies,surface' || !Array.isArray(context.dependencies))
54
+ fail('$html', 'invalid presentation context');
55
+ const surface = validateArtefact(context.surface);
56
+ if (surface.contract !== 'n8r.surface/0.3')
57
+ fail('$html', 'requires Surface 0.3');
58
+ const dependencies = context.dependencies.map(validateArtefact);
59
+ const all = [record, surface, ...dependencies].filter((a, i, items) => {
60
+ const first = items.findIndex(x => x.id === a.id && x.revision.id === a.revision.id);
61
+ if (canonical(items[first]) !== canonical(a))
62
+ fail('$html', 'ambiguous presentation revision');
63
+ return first === i;
64
+ });
65
+ for (const item of all)
66
+ verifyReferences(item, all);
67
+ if (renderBundleHtml({ research: record, surface, dependencies }) !== html)
68
+ fail('$html', 'HTML/data conflict');
69
+ return record;
70
+ }
71
+ if (renderResearchHtml(record) !== html)
72
+ fail('$html', 'HTML/data conflict; preserve bytes and review the edit before regenerating');
73
+ return record;
74
+ }
@@ -0,0 +1,7 @@
1
+ export type * from './contracts.js';
2
+ export { validateArtefact, verifyReferences } from './validate.js';
3
+ export { encode, decode } from './codec.js';
4
+ export type { Encoding } from './codec.js';
5
+ export { digest } from './portable.js';
6
+ export { renderResearchHtml, readResearchHtml } from './html.js';
7
+ export { verifySuccessor } from './revisions.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { validateArtefact, verifyReferences } from './validate.js';
2
+ export { encode, decode } from './codec.js';
3
+ export { digest } from './portable.js';
4
+ export { renderResearchHtml, readResearchHtml } from './html.js';
5
+ export { verifySuccessor } from './revisions.js';
@@ -0,0 +1,52 @@
1
+ export type Evidence = {
2
+ path: string;
3
+ digest: string;
4
+ inspectedAt: string;
5
+ startLine: number;
6
+ endLine: number;
7
+ };
8
+ export type NoteContext = {
9
+ researcher: {
10
+ version: string;
11
+ digest: string;
12
+ };
13
+ model: string;
14
+ evidence: Evidence[];
15
+ };
16
+ type Metadata = NoteContext & {
17
+ version: 1;
18
+ createdAt: string;
19
+ source: {
20
+ name: string;
21
+ kind: 'file' | 'directory';
22
+ };
23
+ predecessor?: {
24
+ file: string;
25
+ digest: string;
26
+ };
27
+ };
28
+ export type Note = {
29
+ path: string;
30
+ text: string;
31
+ digest: string;
32
+ metadata: Metadata;
33
+ modifiedAt: string;
34
+ };
35
+ /** Explicitly selected notes may live outside the vault; their folder becomes the save scope. */
36
+ export declare function readNote(path: string): Note;
37
+ /** Local folder metadata only; never infer identity from the latest filename. */
38
+ export declare function latestNote(vault: string, source: string): Note | undefined;
39
+ /** Synchronous bounded host writer. Directory topology must remain stable during each operation. */
40
+ export declare class Notes {
41
+ readonly directory: string;
42
+ predecessor: Note | undefined;
43
+ private topic;
44
+ private stamps;
45
+ private source;
46
+ private sourceKind;
47
+ constructor(vault: string, source: string, predecessor?: Note);
48
+ private check;
49
+ next(): string;
50
+ save(path: string, markdown: string, context: NoteContext): Note;
51
+ }
52
+ export {};
package/dist/notes.js ADDED
@@ -0,0 +1,184 @@
1
+ import fs from 'node:fs';
2
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { InspectionError, readBytes } from './settings.js';
5
+ const LIMIT = 256 * 1024;
6
+ const MARKER = '\n<!-- n8r-note-v1 -->\n';
7
+ const namePattern = /^([a-z0-9]+(?:-[a-z0-9]+)*)-(\d{3,})\.md$/;
8
+ const digest = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
9
+ const slug = (source) => basename(source).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80) || 'source';
10
+ const inside = (path, root) => { const p = relative(root, path); return !p || (p !== '..' && !p.startsWith(`..${sep}`) && !p.startsWith(sep)); };
11
+ function folder(vault, source) { return join(fs.realpathSync(vault), 'research', slug(source)); }
12
+ function validateMetadata(value) {
13
+ if (!value || value.version !== 1 || !value.source || !['file', 'directory'].includes(value.source.kind)
14
+ || typeof value.source.name !== 'string' || typeof value.createdAt !== 'string'
15
+ || typeof value.model !== 'string' || typeof value.researcher?.version !== 'string' || typeof value.researcher?.digest !== 'string'
16
+ || !Array.isArray(value.evidence) || value.evidence.length > 1000
17
+ || value.evidence.some((e) => !e || typeof e.path !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(e.digest)
18
+ || typeof e.inspectedAt !== 'string' || !Number.isSafeInteger(e.startLine) || !Number.isSafeInteger(e.endLine))
19
+ || (value.predecessor !== undefined && (!namePattern.test(value.predecessor.file) || !/^sha256:[a-f0-9]{64}$/.test(value.predecessor.digest)))) {
20
+ throw new InspectionError('Invalid working-note metadata; preserve the host footer when editing');
21
+ }
22
+ }
23
+ /** Explicitly selected notes may live outside the vault; their folder becomes the save scope. */
24
+ export function readNote(path) {
25
+ path = resolve(path);
26
+ if (!namePattern.test(basename(path)))
27
+ throw new InspectionError('Select a numbered working note such as research-001.md');
28
+ if (fs.lstatSync(path).isSymbolicLink())
29
+ throw new InspectionError('Note cannot be a symbolic link');
30
+ path = join(fs.realpathSync(dirname(path)), basename(path));
31
+ const bytes = readBytes(path, LIMIT);
32
+ let text;
33
+ try {
34
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
35
+ }
36
+ catch {
37
+ throw new InspectionError('Note is not UTF-8 text');
38
+ }
39
+ if (text.includes('\0'))
40
+ throw new InspectionError('Note contains binary data');
41
+ const marker = text.lastIndexOf(MARKER);
42
+ const footer = marker < 0 ? undefined : /^```json\n([\s\S]*?)\n```(?:\n|$)/.exec(text.slice(marker + MARKER.length));
43
+ if (!footer)
44
+ throw new InspectionError('Expected an n8r-surface working note with its host metadata footer');
45
+ let metadata;
46
+ try {
47
+ metadata = JSON.parse(footer[1]);
48
+ }
49
+ catch {
50
+ throw new InspectionError('Invalid working-note metadata JSON');
51
+ }
52
+ validateMetadata(metadata);
53
+ return { path, text, digest: digest(bytes), metadata, modifiedAt: fs.statSync(path).mtime.toISOString() };
54
+ }
55
+ /** Local folder metadata only; never infer identity from the latest filename. */
56
+ export function latestNote(vault, source) {
57
+ const dir = folder(vault, source);
58
+ const scope = new Notes(vault, source); // validate ancestry, including a replaced research folder
59
+ scope.next();
60
+ let entries;
61
+ try {
62
+ entries = fs.readdirSync(dir, { withFileTypes: true });
63
+ }
64
+ catch (error) {
65
+ if (error.code === 'ENOENT')
66
+ return undefined;
67
+ throw error;
68
+ }
69
+ if (entries.length > 10000)
70
+ throw new InspectionError('Too many notes; select an explicit filename');
71
+ const candidates = entries.filter(e => e.isFile() && namePattern.test(e.name))
72
+ .map(e => ({ path: join(dir, e.name), time: fs.statSync(join(dir, e.name)).mtimeMs }))
73
+ .sort((a, b) => b.time - a.time || a.path.localeCompare(b.path));
74
+ return candidates.length ? readNote(candidates[0].path) : undefined;
75
+ }
76
+ /** Synchronous bounded host writer. Directory topology must remain stable during each operation. */
77
+ export class Notes {
78
+ directory;
79
+ predecessor;
80
+ topic;
81
+ stamps = new Map();
82
+ source;
83
+ sourceKind;
84
+ constructor(vault, source, predecessor) {
85
+ this.source = fs.realpathSync(source);
86
+ this.sourceKind = fs.statSync(this.source).isFile() ? 'file' : 'directory';
87
+ this.directory = predecessor ? dirname(predecessor.path) : folder(vault, this.source);
88
+ if (this.sourceKind === 'directory' && inside(this.directory, this.source))
89
+ throw new InspectionError('Notes cannot be saved inside the selected source');
90
+ this.predecessor = predecessor;
91
+ this.topic = predecessor ? namePattern.exec(basename(predecessor.path))[1] : 'research';
92
+ this.check();
93
+ }
94
+ check() {
95
+ const paths = [];
96
+ for (let p = this.directory;; p = dirname(p)) {
97
+ paths.unshift(p);
98
+ if (dirname(p) === p)
99
+ break;
100
+ }
101
+ for (const path of paths) {
102
+ let stat;
103
+ try {
104
+ stat = fs.lstatSync(path);
105
+ }
106
+ catch (error) {
107
+ if (error.code === 'ENOENT' && !this.stamps.has(path))
108
+ continue;
109
+ throw new InspectionError('Note directory changed; reopen before saving');
110
+ }
111
+ if (stat.isSymbolicLink() || !stat.isDirectory())
112
+ throw new InspectionError('Note directory is occupied or a symbolic link');
113
+ const old = this.stamps.get(path);
114
+ if (old && (old.dev !== stat.dev || old.ino !== stat.ino))
115
+ throw new InspectionError('Note directory changed; reopen before saving');
116
+ this.stamps.set(path, { dev: stat.dev, ino: stat.ino });
117
+ }
118
+ }
119
+ next() {
120
+ this.check();
121
+ let names;
122
+ try {
123
+ names = fs.readdirSync(this.directory);
124
+ }
125
+ catch (error) {
126
+ if (error.code === 'ENOENT')
127
+ names = [];
128
+ else
129
+ throw error;
130
+ }
131
+ if (names.length > 10000)
132
+ throw new InspectionError('Too many notes in this folder');
133
+ let n = 0;
134
+ for (const name of names) {
135
+ const match = namePattern.exec(name);
136
+ if (match?.[1] === this.topic)
137
+ n = Math.max(n, Number(match[2]));
138
+ }
139
+ if (!Number.isSafeInteger(n + 1))
140
+ throw new InspectionError('Note numbering limit reached');
141
+ return join(this.directory, `${this.topic}-${String(n + 1).padStart(3, '0')}.md`);
142
+ }
143
+ save(path, markdown, context) {
144
+ this.check();
145
+ if (dirname(path) !== this.directory || namePattern.exec(basename(path))?.[1] !== this.topic || path === this.source)
146
+ throw new InspectionError('Output outside authorized note scope');
147
+ if (typeof markdown !== 'string' || !markdown.trim() || Buffer.byteLength(markdown) > 192 * 1024 || markdown.includes(MARKER) || markdown.includes('\0'))
148
+ throw new InspectionError('Invalid or oversized Markdown draft');
149
+ const unchanged = () => {
150
+ if (this.predecessor && !readBytes(this.predecessor.path, LIMIT).equals(Buffer.from(this.predecessor.text)))
151
+ throw new InspectionError('Predecessor changed; reopen the edited note before saving');
152
+ };
153
+ unchanged();
154
+ const metadata = { ...context, version: 1, createdAt: new Date().toISOString(),
155
+ source: { name: basename(this.source), kind: this.sourceKind },
156
+ ...(this.predecessor ? { predecessor: { file: basename(this.predecessor.path), digest: this.predecessor.digest } } : {}) };
157
+ validateMetadata(metadata);
158
+ const predecessor = metadata.predecessor ? `Predecessor: [${metadata.predecessor.file}](${metadata.predecessor.file}) (${metadata.predecessor.digest}).\n\n` : '';
159
+ const bytes = Buffer.from(predecessor + markdown.trim() + '\n\n## Host provenance\n\nExperimental working-note metadata; not an AI Surface schema. Source paths are\nrelative to the explicitly selected source; hashes identify bytes, not truth.\n' + MARKER + '```json\n' + JSON.stringify(metadata, null, 2) + '\n```\n');
160
+ if (bytes.length > LIMIT)
161
+ throw new InspectionError('Saved note exceeds 256 KiB; narrow the research scope');
162
+ fs.mkdirSync(this.directory, { recursive: true, mode: 0o700 });
163
+ this.check();
164
+ const temporary = join(this.directory, `.note-${randomUUID()}.tmp`);
165
+ const fd = fs.openSync(temporary, 'wx', 0o600);
166
+ try {
167
+ fs.writeFileSync(fd, bytes);
168
+ fs.fsyncSync(fd);
169
+ this.check();
170
+ unchanged();
171
+ // Publish a complete file without replacing any existing file or symlink.
172
+ fs.linkSync(temporary, path);
173
+ const saved = readNote(path);
174
+ if (saved.digest !== digest(bytes))
175
+ throw new InspectionError('Saved note readback differs; inspect the output before retrying');
176
+ this.predecessor = saved;
177
+ return saved;
178
+ }
179
+ finally {
180
+ fs.closeSync(fd);
181
+ fs.unlinkSync(temporary);
182
+ }
183
+ }
184
+ }
@@ -0,0 +1,10 @@
1
+ import type { Value } from './contracts.js';
2
+ export declare const MAX_BYTES: number;
3
+ export declare const MAX_DEPTH = 64;
4
+ export declare function fail(path: string, message: string): never;
5
+ export declare function checkSize(text: string): void;
6
+ /** Reject before serialization: never invoke a getter or silently drop a value. */
7
+ export declare function portable(value: unknown, path?: string, ancestors?: Set<object>, depth?: number): asserts value is Value;
8
+ /** Object keys sorted by UTF-16 code units; arrays retain order; no whitespace. */
9
+ export declare function canonical(value: unknown): string;
10
+ export declare function digest(value: unknown): string;
@@ -0,0 +1,71 @@
1
+ import { createHash } from 'node:crypto';
2
+ export const MAX_BYTES = 2 * 1024 * 1024;
3
+ export const MAX_DEPTH = 64;
4
+ export function fail(path, message) { throw new Error(`${path}: ${message}`); }
5
+ export function checkSize(text) {
6
+ if (Buffer.byteLength(text, 'utf8') > MAX_BYTES)
7
+ fail('$', 'exceeds 2 MiB limit');
8
+ }
9
+ /** Reject before serialization: never invoke a getter or silently drop a value. */
10
+ export function portable(value, path = '$', ancestors = new Set(), depth = 0) {
11
+ if (depth > MAX_DEPTH)
12
+ fail(path, 'exceeds depth 64');
13
+ if (value === null || typeof value === 'boolean')
14
+ return;
15
+ if (typeof value === 'string') {
16
+ if (!value.isWellFormed())
17
+ fail(path, 'unpaired Unicode surrogate');
18
+ return;
19
+ }
20
+ if (typeof value === 'number') {
21
+ if (!Number.isSafeInteger(value) || Object.is(value, -0))
22
+ fail(path, 'requires a safe integer (no negative zero)');
23
+ return;
24
+ }
25
+ if (typeof value !== 'object')
26
+ fail(path, 'unsupported portable value');
27
+ if (ancestors.has(value))
28
+ fail(path, 'object cycle; use identified graph edges');
29
+ const array = Array.isArray(value);
30
+ const prototype = Object.getPrototypeOf(value);
31
+ if (array && prototype !== Array.prototype)
32
+ fail(path, 'requires a plain array');
33
+ if (!array && prototype !== Object.prototype && prototype !== null)
34
+ fail(path, 'requires a plain object');
35
+ const descriptors = Object.getOwnPropertyDescriptors(value);
36
+ const keys = Reflect.ownKeys(value);
37
+ if (array && (keys.length !== value.length + 1))
38
+ fail(path, 'sparse array or extra array properties');
39
+ ancestors.add(value);
40
+ for (const key of keys) {
41
+ if (array && key === 'length')
42
+ continue;
43
+ if (typeof key !== 'string')
44
+ fail(path, 'symbol key');
45
+ portable(key, path);
46
+ if (array && (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= value.length))
47
+ fail(path, 'extra array property');
48
+ const descriptor = descriptors[key];
49
+ if (!('value' in descriptor) || !descriptor.enumerable)
50
+ fail(`${path}.${key}`, 'requires enumerable data property');
51
+ portable(descriptor.value, `${path}.${key}`, ancestors, depth + 1);
52
+ }
53
+ ancestors.delete(value);
54
+ }
55
+ /** Object keys sorted by UTF-16 code units; arrays retain order; no whitespace. */
56
+ export function canonical(value) {
57
+ portable(value);
58
+ const write = (v) => {
59
+ if (Array.isArray(v))
60
+ return `[${v.map(write).join(',')}]`;
61
+ if (v !== null && typeof v === 'object')
62
+ return `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${write(v[k])}`).join(',')}}`;
63
+ return JSON.stringify(v);
64
+ };
65
+ const text = write(value);
66
+ checkSize(text);
67
+ return text;
68
+ }
69
+ export function digest(value) {
70
+ return `sha256:${createHash('sha256').update(canonical(value), 'utf8').digest('hex')}`;
71
+ }
@@ -0,0 +1,9 @@
1
+ export type RecentWork = {
2
+ id: string;
3
+ title: string;
4
+ source: string;
5
+ path: string;
6
+ model: string;
7
+ };
8
+ export declare function recentWork(home: string): RecentWork[];
9
+ export declare function rememberWork(home: string, item: RecentWork): void;
package/dist/recent.js ADDED
@@ -0,0 +1,71 @@
1
+ /** Local navigation metadata only; never included in portable research artifacts. */
2
+ import fs from 'node:fs';
3
+ import { join, resolve, relative, sep } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { InspectionError, readBytes } from './settings.js';
6
+ function location(home) {
7
+ const directory = join(fs.realpathSync(home), '.n8r');
8
+ try {
9
+ const stat = fs.lstatSync(directory);
10
+ if (!stat.isDirectory() || stat.isSymbolicLink())
11
+ throw new InspectionError('Recent-work folder is not a regular directory');
12
+ }
13
+ catch (e) {
14
+ if (e.code !== 'ENOENT')
15
+ throw e;
16
+ }
17
+ return { directory, file: join(directory, 'recent.json') };
18
+ }
19
+ function valid(item) {
20
+ return item && ['id', 'title', 'source', 'path', 'model'].every(k => typeof item[k] === 'string' && item[k].length > 0 && item[k].length < 16000)
21
+ && resolve(item.source) === item.source && resolve(item.path) === item.path;
22
+ }
23
+ export function recentWork(home) {
24
+ const { file } = location(home);
25
+ let bytes;
26
+ try {
27
+ bytes = readBytes(file, 1024 * 1024);
28
+ }
29
+ catch (e) {
30
+ if (e.code === 'ENOENT')
31
+ return [];
32
+ throw e;
33
+ }
34
+ let value;
35
+ try {
36
+ value = JSON.parse(bytes.toString('utf8'));
37
+ }
38
+ catch {
39
+ throw new InspectionError('Recent-work list is damaged; saved research is unchanged');
40
+ }
41
+ if (!Array.isArray(value) || value.length > 100 || !value.every(valid))
42
+ throw new InspectionError('Invalid recent-work list; saved research is unchanged');
43
+ return value;
44
+ }
45
+ export function rememberWork(home, item) {
46
+ if (!valid(item))
47
+ throw new InspectionError('Invalid saved-work reference');
48
+ const { directory, file } = location(home);
49
+ const rel = relative(item.source, directory);
50
+ if (!rel || (!rel.startsWith(`..${sep}`) && rel !== '..' && !rel.startsWith(sep)))
51
+ throw new InspectionError('Recent-work list cannot be written inside the research source');
52
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
53
+ const lock = file + '.lock';
54
+ const fd = fs.openSync(lock, 'wx', 0o600);
55
+ const temporary = file + '.' + randomUUID();
56
+ try {
57
+ const previous = recentWork(home);
58
+ const next = [item, ...previous.filter(p => p.id !== item.id)].slice(0, 100);
59
+ fs.writeFileSync(temporary, JSON.stringify(next, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
60
+ // Check again while holding our lock; refuse user edits during this save.
61
+ if (JSON.stringify(recentWork(home)) !== JSON.stringify(previous))
62
+ throw new InspectionError('Recent-work list changed; saved research is unchanged');
63
+ fs.renameSync(temporary, file);
64
+ }
65
+ finally {
66
+ fs.closeSync(fd);
67
+ fs.unlinkSync(lock);
68
+ if (fs.existsSync(temporary))
69
+ fs.unlinkSync(temporary);
70
+ }
71
+ }
@@ -0,0 +1,2 @@
1
+ /** Validate a proposed successor; persistence and complete history belong to the host. */
2
+ export declare function verifySuccessor(previous: unknown, next: unknown): void;
@@ -0,0 +1,60 @@
1
+ import { validateArtefact } from './validate.js';
2
+ import { canonical, digest, fail } from './portable.js';
3
+ /** Validate a proposed successor; persistence and complete history belong to the host. */
4
+ export function verifySuccessor(previous, next) {
5
+ const before = validateArtefact(previous), after = validateArtefact(next);
6
+ if (before.contract !== after.contract || before.id !== after.id)
7
+ fail('$revision', 'contract/artefact identity changed');
8
+ const parent = after.revision.parent;
9
+ if (!parent || parent.id !== before.id || parent.revision !== before.revision.id || parent.digest !== digest(before) || after.revision.id === before.revision.id)
10
+ fail('$revision', 'parent revision/digest mismatch or reused revision');
11
+ if (before.contract === 'n8r.surface/0.2' && after.contract === 'n8r.surface/0.2') {
12
+ if (before.kind !== after.kind)
13
+ fail('$revision', 'description kind changed');
14
+ for (const n of after.nodes) {
15
+ const old = before.nodes.find(x => x.id === n.id);
16
+ if (old && canonical(old.type) !== canonical(n.type))
17
+ fail('$revision', 'retained node type changed');
18
+ }
19
+ if (before.kind === 'capability' && after.kind === 'capability') {
20
+ for (const c of after.capabilities) {
21
+ const old = before.capabilities.find(x => x.id === c.id);
22
+ if (old && (old.from !== c.from || old.to !== c.to || canonical(old.type) !== canonical(c.type)))
23
+ fail('$revision', 'retained capability endpoints/type changed');
24
+ }
25
+ }
26
+ if (before.kind === 'bound' && after.kind === 'bound') {
27
+ if (before.instance.id !== after.instance.id || before.capability.id !== after.capability.id)
28
+ fail('$revision', 'instance/capability identity changed');
29
+ for (const b of after.bindings) {
30
+ const old = before.bindings.find(x => x.id === b.id);
31
+ if (old && old.capability !== b.capability)
32
+ fail('$revision', 'retained binding slot changed');
33
+ }
34
+ }
35
+ }
36
+ if (before.contract === 'n8r.surface/0.3' && after.contract === 'n8r.surface/0.3') {
37
+ if (before.kind !== after.kind)
38
+ fail('$revision', 'description kind changed');
39
+ for (const n of after.nodes) {
40
+ const old = before.nodes.find(x => x.id === n.id);
41
+ if (old && canonical(old.type) !== canonical(n.type))
42
+ fail('$revision', 'retained node type changed');
43
+ }
44
+ for (const edge of after.relationships) {
45
+ const old = before.relationships.find(x => x.id === edge.id);
46
+ if (old && (old.from !== edge.from || old.to !== edge.to || canonical(old.type) !== canonical(edge.type)))
47
+ fail('$revision', 'retained relationship endpoints/type changed');
48
+ }
49
+ if (before.kind === 'bound' && after.kind === 'bound' &&
50
+ (before.instance.id !== after.instance.id || before.capability.id !== after.capability.id))
51
+ fail('$revision', 'instance/capability identity changed');
52
+ }
53
+ if (before.contract === 'n8r.surface/0.1' && after.contract === 'n8r.surface/0.1') {
54
+ for (const edge of after.relationships) {
55
+ const old = before.relationships.find(x => x.id === edge.id);
56
+ if (old && (old.from !== edge.from || old.to !== edge.to || canonical(old.type) !== canonical(edge.type)))
57
+ fail(edge.id, 'retained relationship identity has different endpoints/type');
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,23 @@
1
+ /** Escape terminal controls and line/bidi separators in untrusted labels. */
2
+ export declare function line(value: string): string;
3
+ export declare class InspectionError extends Error {
4
+ }
5
+ export declare function problem(error: unknown): string;
6
+ /** Do not block on special files, follow entry symlinks or read unbounded data. */
7
+ export declare function readBytes(path: string, limit: number): Buffer;
8
+ export interface Settings {
9
+ home: string;
10
+ file: string;
11
+ original: Buffer | undefined;
12
+ data: Record<string, unknown>;
13
+ vault: string | undefined;
14
+ root: string;
15
+ format: 'json' | 'yaml';
16
+ defaultRoot: boolean;
17
+ defaultFormat: boolean;
18
+ runner: boolean;
19
+ lab: boolean;
20
+ }
21
+ export declare function localPath(value: unknown, home: string, base: string): string;
22
+ export declare function readLocal(path: string, limit: number): string;
23
+ export declare function loadSettings(home?: string): Settings;