@tiwater/office-mcp 0.21.29 → 0.21.31
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/_shared/evidence-role.mjs +123 -0
- package/office/contracts/docx_export_json.schema.json +2 -1
- package/office/contracts/docx_inspect.schema.json +2 -1
- package/office/contracts/pptx_export_json.schema.json +2 -1
- package/office/contracts/pptx_inspect.schema.json +2 -1
- package/office/contracts/tiwater-office-provider-contract-manifest-v1.json +13 -13
- package/office/contracts/xlsx_export_json.schema.json +2 -1
- package/office/contracts/xlsx_inspect.schema.json +2 -1
- package/office/index.mjs +16 -6
- package/package.json +11 -4
- package/pdf/contracts/tiwater-pdf-provider-contract-manifest-v1.json +1 -1
- package/pdf/index.mjs +3 -0
- package/text/README.md +14 -0
- package/text/contracts/text_inspect.schema.json +25 -0
- package/text/contracts/text_read_lines.schema.json +36 -0
- package/text/contracts/tiwater-text-provider-contract-manifest-v1.json +31 -0
- package/text/index.mjs +183 -0
- package/text/observation.mjs +162 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export const evidenceRoleMetadataKey = 'x-tiwater-evidence-role';
|
|
2
|
+
export const evidenceRoleSchema = 'tiwater.provider-evidence-role/v1';
|
|
3
|
+
|
|
4
|
+
const evidenceRoles = new Set([
|
|
5
|
+
'document-observation',
|
|
6
|
+
'final-readback',
|
|
7
|
+
'native-render',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export function evidenceRoleMetadata(role) {
|
|
11
|
+
if (!evidenceRoles.has(role)) throw new Error(`unsupported-evidence-role:${role}`);
|
|
12
|
+
return {
|
|
13
|
+
[evidenceRoleMetadataKey]: {
|
|
14
|
+
schema: evidenceRoleSchema,
|
|
15
|
+
role,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function assertEvidenceToolContract(tool, expectedRole) {
|
|
21
|
+
const metadata = tool?._meta?.[evidenceRoleMetadataKey];
|
|
22
|
+
if (!metadata || Object.keys(metadata).sort().join(',') !== 'role,schema'
|
|
23
|
+
|| metadata.schema !== evidenceRoleSchema || metadata.role !== expectedRole) {
|
|
24
|
+
throw new Error(`evidence-role-metadata-invalid:${tool?.name || 'unnamed'}`);
|
|
25
|
+
}
|
|
26
|
+
const bindings = fileBindings(tool.inputSchema);
|
|
27
|
+
if (!bindings.some(binding => binding.role === 'read')) {
|
|
28
|
+
throw new Error(`evidence-role-source-binding-missing:${tool.name}`);
|
|
29
|
+
}
|
|
30
|
+
if (!tool.outputSchema || typeof tool.outputSchema !== 'object') {
|
|
31
|
+
throw new Error(`evidence-role-output-schema-missing:${tool.name}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (expectedRole === 'native-render') {
|
|
35
|
+
assertAnnotations(tool, false, false);
|
|
36
|
+
if (!bindings.some(binding => binding.role === 'write' && binding.effect)
|
|
37
|
+
|| !bindings.some(binding => binding.role === 'write' && !binding.effect)
|
|
38
|
+
|| !requiredArtifact(tool.outputSchema, 'receipt')) {
|
|
39
|
+
throw new Error(`native-render-evidence-contract-invalid:${tool.name}`);
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
assertAnnotations(tool, true, true);
|
|
45
|
+
const writes = bindings.filter(binding => binding.role === 'write');
|
|
46
|
+
if (writes.length < 1 || writes.some(binding => binding.effect)) {
|
|
47
|
+
throw new Error(`read-evidence-artifact-binding-invalid:${tool.name}`);
|
|
48
|
+
}
|
|
49
|
+
if (!sourceIdentity(tool.outputSchema) || !requiredArtifact(tool.outputSchema, 'artifact')) {
|
|
50
|
+
throw new Error(`read-evidence-output-binding-invalid:${tool.name}`);
|
|
51
|
+
}
|
|
52
|
+
if (expectedRole === 'document-observation') {
|
|
53
|
+
const identity = requiredObject(tool.outputSchema, 'identity') || requiredObject(tool.outputSchema, 'summary');
|
|
54
|
+
if (!identity || unboundedArrays(identity).length > 0) {
|
|
55
|
+
throw new Error(`document-observation-identity-unbounded:${tool.name}`);
|
|
56
|
+
}
|
|
57
|
+
} else if (!requiredObject(tool.outputSchema, 'receipt')) {
|
|
58
|
+
throw new Error(`final-readback-receipt-missing:${tool.name}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function assertAnnotations(tool, readOnlyHint, idempotentHint) {
|
|
63
|
+
const annotations = tool.annotations || {};
|
|
64
|
+
if (annotations.readOnlyHint !== readOnlyHint
|
|
65
|
+
|| annotations.idempotentHint !== idempotentHint
|
|
66
|
+
|| annotations.destructiveHint !== false
|
|
67
|
+
|| annotations.openWorldHint !== false) {
|
|
68
|
+
throw new Error(`evidence-role-annotations-invalid:${tool.name}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function fileBindings(schema) {
|
|
73
|
+
const bindings = [];
|
|
74
|
+
function visit(node) {
|
|
75
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
|
|
76
|
+
if (node['x-tiwater-file-role'] === 'read' || node['x-tiwater-file-role'] === 'write') {
|
|
77
|
+
bindings.push({
|
|
78
|
+
role: node['x-tiwater-file-role'],
|
|
79
|
+
effect: node['x-tiwater-file-role'] === 'write' && node['x-tiwater-file-effect'] !== false,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
for (const child of Object.values(node.properties || {})) visit(child);
|
|
83
|
+
if (node.items) visit(node.items);
|
|
84
|
+
for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
|
|
85
|
+
for (const child of node[keyword] || []) visit(child);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
visit(schema);
|
|
89
|
+
return bindings;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function requiredObject(schema, property) {
|
|
93
|
+
const value = schema?.properties?.[property];
|
|
94
|
+
return schema?.required?.includes(property) && value?.type === 'object' ? value : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function artifactShape(schema) {
|
|
98
|
+
const candidate = schema?.anyOf?.find(value => value?.type === 'object') || schema;
|
|
99
|
+
return candidate?.type === 'object'
|
|
100
|
+
&& ['path', 'sha256', 'bytes'].every(property => candidate.required?.includes(property));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function requiredArtifact(schema, property) {
|
|
104
|
+
return schema?.required?.includes(property) && artifactShape(schema?.properties?.[property]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sourceIdentity(schema) {
|
|
108
|
+
if (requiredArtifact(schema, 'source')) return true;
|
|
109
|
+
const sources = schema?.properties?.sources;
|
|
110
|
+
return schema?.required?.includes('sources') && sources?.type === 'array'
|
|
111
|
+
&& Number.isInteger(sources.maxItems) && artifactShape(sources.items);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function unboundedArrays(schema, location = '$', found = []) {
|
|
115
|
+
if (Array.isArray(schema)) {
|
|
116
|
+
schema.forEach((child, index) => unboundedArrays(child, `${location}[${index}]`, found));
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
119
|
+
if (!schema || typeof schema !== 'object') return found;
|
|
120
|
+
if (schema.type === 'array' && !Number.isInteger(schema.maxItems)) found.push(location);
|
|
121
|
+
for (const [key, child] of Object.entries(schema)) unboundedArrays(child, `${location}.${key}`, found);
|
|
122
|
+
return found;
|
|
123
|
+
}
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"type": "string",
|
|
16
16
|
"minLength": 1,
|
|
17
17
|
"description": "Absolute path for a new JSON artifact inside the caller-authorized output directory. Existing files are never overwritten.",
|
|
18
|
-
"x-tiwater-file-role": "write"
|
|
18
|
+
"x-tiwater-file-role": "write",
|
|
19
|
+
"x-tiwater-file-effect": false
|
|
19
20
|
}
|
|
20
21
|
},
|
|
21
22
|
"required": [
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"type": "string",
|
|
16
16
|
"minLength": 1,
|
|
17
17
|
"description": "Optional absolute path for retaining the complete observation inside the caller-authorized output directory; existing files are never overwritten. May be combined with returnContent.",
|
|
18
|
-
"x-tiwater-file-role": "write"
|
|
18
|
+
"x-tiwater-file-role": "write",
|
|
19
|
+
"x-tiwater-file-effect": false
|
|
19
20
|
}
|
|
20
21
|
},
|
|
21
22
|
"required": [
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema": "tiwater.office-provider-contract-manifest/v1",
|
|
3
3
|
"provider": {
|
|
4
4
|
"id": "@tiwater/office-mcp",
|
|
5
|
-
"version": "0.21.
|
|
5
|
+
"version": "0.21.31"
|
|
6
6
|
},
|
|
7
7
|
"tools": [
|
|
8
8
|
{
|
|
@@ -64,11 +64,11 @@
|
|
|
64
64
|
"name": "docx_export_json",
|
|
65
65
|
"providerContract": {
|
|
66
66
|
"source": "packages/docx-cli/contracts/mcp-input/docx_export_json.schema.json",
|
|
67
|
-
"sha256": "
|
|
67
|
+
"sha256": "995eb1a5a8992e2a2186f2d7621b5eea7875eecd78ce153e77964d2942db2211"
|
|
68
68
|
},
|
|
69
69
|
"inputContract": {
|
|
70
70
|
"path": "office/contracts/docx_export_json.schema.json",
|
|
71
|
-
"sha256": "
|
|
71
|
+
"sha256": "995eb1a5a8992e2a2186f2d7621b5eea7875eecd78ce153e77964d2942db2211"
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
74
|
{
|
|
@@ -108,11 +108,11 @@
|
|
|
108
108
|
"name": "docx_inspect",
|
|
109
109
|
"providerContract": {
|
|
110
110
|
"source": "packages/docx-cli/contracts/mcp-input/docx_inspect.schema.json",
|
|
111
|
-
"sha256": "
|
|
111
|
+
"sha256": "a7d00a4caa5983d5dbf210eade9ebcc345afaa9f6c72dcae37978447ad180259"
|
|
112
112
|
},
|
|
113
113
|
"inputContract": {
|
|
114
114
|
"path": "office/contracts/docx_inspect.schema.json",
|
|
115
|
-
"sha256": "
|
|
115
|
+
"sha256": "a7d00a4caa5983d5dbf210eade9ebcc345afaa9f6c72dcae37978447ad180259"
|
|
116
116
|
}
|
|
117
117
|
},
|
|
118
118
|
{
|
|
@@ -328,22 +328,22 @@
|
|
|
328
328
|
"name": "pptx_export_json",
|
|
329
329
|
"providerContract": {
|
|
330
330
|
"source": "packages/pptx-cli/contracts/mcp-input/pptx_export_json.schema.json",
|
|
331
|
-
"sha256": "
|
|
331
|
+
"sha256": "9cde8fdda335f379d44e60819367b305a2c97c6c0c8fdf2141bf460335e5da84"
|
|
332
332
|
},
|
|
333
333
|
"inputContract": {
|
|
334
334
|
"path": "office/contracts/pptx_export_json.schema.json",
|
|
335
|
-
"sha256": "
|
|
335
|
+
"sha256": "9cde8fdda335f379d44e60819367b305a2c97c6c0c8fdf2141bf460335e5da84"
|
|
336
336
|
}
|
|
337
337
|
},
|
|
338
338
|
{
|
|
339
339
|
"name": "pptx_inspect",
|
|
340
340
|
"providerContract": {
|
|
341
341
|
"source": "packages/pptx-cli/contracts/mcp-input/pptx_inspect.schema.json",
|
|
342
|
-
"sha256": "
|
|
342
|
+
"sha256": "9cde8fdda335f379d44e60819367b305a2c97c6c0c8fdf2141bf460335e5da84"
|
|
343
343
|
},
|
|
344
344
|
"inputContract": {
|
|
345
345
|
"path": "office/contracts/pptx_inspect.schema.json",
|
|
346
|
-
"sha256": "
|
|
346
|
+
"sha256": "9cde8fdda335f379d44e60819367b305a2c97c6c0c8fdf2141bf460335e5da84"
|
|
347
347
|
}
|
|
348
348
|
},
|
|
349
349
|
{
|
|
@@ -427,11 +427,11 @@
|
|
|
427
427
|
"name": "xlsx_export_json",
|
|
428
428
|
"providerContract": {
|
|
429
429
|
"source": "packages/xlsx-cli/contracts/mcp-input/xlsx_export_json.schema.json",
|
|
430
|
-
"sha256": "
|
|
430
|
+
"sha256": "3943fa01506e5b9ea1cb0ecfd13fc484c16ec96245af462c9a6a37377f2a7a82"
|
|
431
431
|
},
|
|
432
432
|
"inputContract": {
|
|
433
433
|
"path": "office/contracts/xlsx_export_json.schema.json",
|
|
434
|
-
"sha256": "
|
|
434
|
+
"sha256": "3943fa01506e5b9ea1cb0ecfd13fc484c16ec96245af462c9a6a37377f2a7a82"
|
|
435
435
|
}
|
|
436
436
|
},
|
|
437
437
|
{
|
|
@@ -449,11 +449,11 @@
|
|
|
449
449
|
"name": "xlsx_inspect",
|
|
450
450
|
"providerContract": {
|
|
451
451
|
"source": "packages/xlsx-cli/contracts/mcp-input/xlsx_inspect.schema.json",
|
|
452
|
-
"sha256": "
|
|
452
|
+
"sha256": "9cde8fdda335f379d44e60819367b305a2c97c6c0c8fdf2141bf460335e5da84"
|
|
453
453
|
},
|
|
454
454
|
"inputContract": {
|
|
455
455
|
"path": "office/contracts/xlsx_inspect.schema.json",
|
|
456
|
-
"sha256": "
|
|
456
|
+
"sha256": "9cde8fdda335f379d44e60819367b305a2c97c6c0c8fdf2141bf460335e5da84"
|
|
457
457
|
}
|
|
458
458
|
},
|
|
459
459
|
{
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"type": "string",
|
|
16
16
|
"minLength": 1,
|
|
17
17
|
"description": "New JSON artifact path. Existing files are never overwritten.",
|
|
18
|
-
"x-tiwater-file-role": "write"
|
|
18
|
+
"x-tiwater-file-role": "write",
|
|
19
|
+
"x-tiwater-file-effect": false
|
|
19
20
|
},
|
|
20
21
|
"resolveMergedCells": {
|
|
21
22
|
"description": "Resolve merged cells to project values.",
|
package/office/index.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
writeJsonArtifact,
|
|
24
24
|
} from '../_shared/large-json-result.mjs';
|
|
25
25
|
import { withOutputWriteLock } from '../_shared/output-write-lock.mjs';
|
|
26
|
+
import { evidenceRoleMetadata } from '../_shared/evidence-role.mjs';
|
|
26
27
|
import { compactDocxObjectIdentity } from './docx-object-identity.mjs';
|
|
27
28
|
|
|
28
29
|
const packageMetadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
@@ -424,10 +425,11 @@ const docxReadObjectOutput = docxObservationOutput('docx_read_object').extend({
|
|
|
424
425
|
const tools = [
|
|
425
426
|
{
|
|
426
427
|
name: 'docx_inspect',
|
|
428
|
+
evidenceRole: 'document-observation',
|
|
427
429
|
description: 'Inspect one current DOCX for identity and package overview. The response always includes a bounded identity summary. Set returnContent true when that summary is the requested direct result. Provide output to retain the complete machine observation and return its artifact receipt. These channels are independent and may be used together. At least one channel is required. Use list and read operations to traverse selected document objects in native structure order. This overview is not a complete final-document readback; use docx_export_json when a downstream consumer requires the complete body projection.',
|
|
428
430
|
inputSchema: inputContract('docx_inspect'),
|
|
429
431
|
outputSchema: docxInspectionOutput('docx_inspect'),
|
|
430
|
-
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
432
|
+
annotations: { readOnlyHint: true, idempotentHint: true, destructiveHint: false, openWorldHint: false },
|
|
431
433
|
handler: docxInspect,
|
|
432
434
|
},
|
|
433
435
|
{
|
|
@@ -549,10 +551,11 @@ const tools = [
|
|
|
549
551
|
},
|
|
550
552
|
{
|
|
551
553
|
name: 'docx_export_json',
|
|
554
|
+
evidenceRole: 'final-readback',
|
|
552
555
|
description: 'Produce the complete body-only DOCX JSON projection required for final-document readback or another downstream consumer of that format. Set returnContent true to return the complete result when it fits the response limit. Provide output to write the complete result to a new JSON file. The two choices are independent and may be used together; at least one is required. This does not replace bounded list and read operations during document processing.',
|
|
553
556
|
inputSchema: inputContract('docx_export_json'),
|
|
554
557
|
outputSchema: largeResultOutput('docx_export_json'),
|
|
555
|
-
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
558
|
+
annotations: { readOnlyHint: true, idempotentHint: true, destructiveHint: false, openWorldHint: false },
|
|
556
559
|
handler: docxExportJson,
|
|
557
560
|
},
|
|
558
561
|
{
|
|
@@ -614,9 +617,11 @@ const tools = [
|
|
|
614
617
|
},
|
|
615
618
|
{
|
|
616
619
|
name: 'office_render_pdf',
|
|
620
|
+
evidenceRole: 'native-render',
|
|
617
621
|
description: 'Render a current Office document to PDF with its required native WPS backend and write the complete provider receipt as evidence. The input extension selects Writer, Spreadsheets, or Presentation; fallback rendering is rejected.',
|
|
618
622
|
inputSchema: inputContract('office_render_pdf'),
|
|
619
623
|
outputSchema: nativeRenderOutput,
|
|
624
|
+
annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: false, openWorldHint: false },
|
|
620
625
|
handler: officeRenderPdf,
|
|
621
626
|
},
|
|
622
627
|
{
|
|
@@ -627,18 +632,20 @@ const tools = [
|
|
|
627
632
|
},
|
|
628
633
|
{
|
|
629
634
|
name: 'xlsx_inspect',
|
|
635
|
+
evidenceRole: 'document-observation',
|
|
630
636
|
description: 'Inspect a current XLSX workbook or legacy XLS workbook, including workbook structure, exported values, formulas, styles, merged ranges, and published legacy-format conversion evidence. Set returnContent true to return the complete result when it fits the response limit. Provide output to write the complete result to a new JSON file. The two choices are independent and may be used together; at least one is required.',
|
|
631
637
|
inputSchema: inputContract('xlsx_inspect'),
|
|
632
638
|
outputSchema: largeInspectionOutput('xlsx_inspect', xlsxInspectionSummary),
|
|
633
|
-
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
639
|
+
annotations: { readOnlyHint: true, idempotentHint: true, destructiveHint: false, openWorldHint: false },
|
|
634
640
|
handler: xlsxInspect,
|
|
635
641
|
},
|
|
636
642
|
{
|
|
637
643
|
name: 'xlsx_export_json',
|
|
644
|
+
evidenceRole: 'final-readback',
|
|
638
645
|
description: 'Export workbook sheet data from XLSX as structured JSON. Set returnContent true to return the complete result when it fits the response limit. Provide output to write the complete result to a new JSON file. The two choices are independent and may be used together; at least one is required.',
|
|
639
646
|
inputSchema: inputContract('xlsx_export_json'),
|
|
640
647
|
outputSchema: largeResultOutput('xlsx_export_json'),
|
|
641
|
-
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
648
|
+
annotations: { readOnlyHint: true, idempotentHint: true, destructiveHint: false, openWorldHint: false },
|
|
642
649
|
handler: xlsxExportJson,
|
|
643
650
|
},
|
|
644
651
|
{
|
|
@@ -660,18 +667,20 @@ const tools = [
|
|
|
660
667
|
},
|
|
661
668
|
{
|
|
662
669
|
name: 'pptx_inspect',
|
|
670
|
+
evidenceRole: 'document-observation',
|
|
663
671
|
description: 'Inspect a PPTX file, including slides, masters, layouts, shapes, transforms, paragraphs, runs, and placeholders. Set returnContent true to return the complete result when it fits the response limit. Provide output to write the complete result to a new JSON file. The two choices are independent and may be used together; at least one is required.',
|
|
664
672
|
inputSchema: inputContract('pptx_inspect'),
|
|
665
673
|
outputSchema: largeInspectionOutput('pptx_inspect', pptxInspectionSummary),
|
|
666
|
-
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
674
|
+
annotations: { readOnlyHint: true, idempotentHint: true, destructiveHint: false, openWorldHint: false },
|
|
667
675
|
handler: pptxInspect,
|
|
668
676
|
},
|
|
669
677
|
{
|
|
670
678
|
name: 'pptx_export_json',
|
|
679
|
+
evidenceRole: 'final-readback',
|
|
671
680
|
description: 'Export PPTX slide text, notes, and placeholder hints as structured JSON. Set returnContent true to return the complete result when it fits the response limit. Provide output to write the complete result to a new JSON file. The two choices are independent and may be used together; at least one is required.',
|
|
672
681
|
inputSchema: inputContract('pptx_export_json'),
|
|
673
682
|
outputSchema: largeResultOutput('pptx_export_json'),
|
|
674
|
-
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
683
|
+
annotations: { readOnlyHint: true, idempotentHint: true, destructiveHint: false, openWorldHint: false },
|
|
675
684
|
handler: pptxExportJson,
|
|
676
685
|
},
|
|
677
686
|
{
|
|
@@ -727,6 +736,7 @@ function buildServer() {
|
|
|
727
736
|
inputSchema: tool.inputSchema,
|
|
728
737
|
...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
|
|
729
738
|
...(tool.annotations ? { annotations: tool.annotations } : {}),
|
|
739
|
+
...(tool.evidenceRole ? { _meta: evidenceRoleMetadata(tool.evidenceRole) } : {}),
|
|
730
740
|
},
|
|
731
741
|
async args => {
|
|
732
742
|
const payload = typeof args.output === 'string'
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiwater/office-mcp",
|
|
3
|
-
"version": "0.21.
|
|
4
|
-
"description": "Published MCP distribution for independent Tiwater Office and
|
|
3
|
+
"version": "0.21.31",
|
|
4
|
+
"description": "Published MCP distribution for independent Tiwater Office, PDF, and Text document capabilities",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
},
|
|
12
12
|
"bin": {
|
|
13
13
|
"tiwater-office-mcp": "office/index.mjs",
|
|
14
|
-
"tiwater-pdf-mcp": "pdf/index.mjs"
|
|
14
|
+
"tiwater-pdf-mcp": "pdf/index.mjs",
|
|
15
|
+
"tiwater-text-mcp": "text/index.mjs"
|
|
15
16
|
},
|
|
16
17
|
"engines": {
|
|
17
18
|
"node": ">=20"
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
"_shared/large-json-result.mjs",
|
|
22
23
|
"_shared/output-write-lock.mjs",
|
|
23
24
|
"_shared/mcp-stdio.mjs",
|
|
25
|
+
"_shared/evidence-role.mjs",
|
|
24
26
|
"office/index.mjs",
|
|
25
27
|
"office/docx-object-identity.mjs",
|
|
26
28
|
"office/README.md",
|
|
@@ -28,7 +30,12 @@
|
|
|
28
30
|
"office/contracts/*.schema.json",
|
|
29
31
|
"pdf/index.mjs",
|
|
30
32
|
"pdf/README.md",
|
|
31
|
-
"pdf/contracts/*.json"
|
|
33
|
+
"pdf/contracts/*.json",
|
|
34
|
+
"text/index.mjs",
|
|
35
|
+
"text/observation.mjs",
|
|
36
|
+
"text/README.md",
|
|
37
|
+
"text/contracts/tiwater-text-provider-contract-manifest-v1.json",
|
|
38
|
+
"text/contracts/*.schema.json"
|
|
32
39
|
],
|
|
33
40
|
"publishConfig": {
|
|
34
41
|
"access": "public"
|
package/pdf/index.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
|
|
8
8
|
import { deliverLargeJsonResult } from '../_shared/large-json-result.mjs';
|
|
9
9
|
import { McpStdioServer } from '../_shared/mcp-stdio.mjs';
|
|
10
|
+
import { evidenceRoleMetadata } from '../_shared/evidence-role.mjs';
|
|
10
11
|
import {
|
|
11
12
|
commandCandidate,
|
|
12
13
|
createToolResult,
|
|
@@ -110,6 +111,7 @@ const definitions = new Map([
|
|
|
110
111
|
['pdf_inspect', {
|
|
111
112
|
description: 'Inspect one current PDF revision. Always retain the complete observation at output and return a bounded identity containing page and document metadata without traversing document content.',
|
|
112
113
|
outputSchema: inspectOutputSchema,
|
|
114
|
+
evidenceRole: 'document-observation',
|
|
113
115
|
}],
|
|
114
116
|
['pdf_extract_tables', {
|
|
115
117
|
description: 'Extract tables from selected current PDF pages with deterministic published extraction. Set returnContent true to return complete bounded content; provide output to retain the complete immutable result. At least one result channel is required.',
|
|
@@ -142,6 +144,7 @@ const tools = manifest.tools.map((entry) => {
|
|
|
142
144
|
description: definition.description,
|
|
143
145
|
inputSchema: JSON.parse(bytes.toString('utf8')),
|
|
144
146
|
outputSchema: definition.outputSchema,
|
|
147
|
+
...(definition.evidenceRole ? { _meta: evidenceRoleMetadata(definition.evidenceRole) } : {}),
|
|
145
148
|
annotations: {
|
|
146
149
|
readOnlyHint: true,
|
|
147
150
|
idempotentHint: true,
|
package/text/README.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# tiwater-text-mcp
|
|
2
|
+
|
|
3
|
+
`tiwater-text-mcp` publishes read-only observation for explicitly supported
|
|
4
|
+
plain-text files. `text_inspect` reports the exact byte identity, lossless
|
|
5
|
+
decoding facts, line count, and bounded opening lines. `text_read_lines` reads
|
|
6
|
+
one explicit zero-based line page and reports its continuation.
|
|
7
|
+
|
|
8
|
+
Supported inputs are `.txt`, `.text`, `.log`, `.csv`, `.tsv`, `.md`, and
|
|
9
|
+
`.markdown` files encoded as valid UTF-8, UTF-8 with BOM, UTF-16LE with BOM, or
|
|
10
|
+
UTF-16BE with BOM. The provider rejects binary content and never guesses an
|
|
11
|
+
encoding.
|
|
12
|
+
|
|
13
|
+
The tools do not interpret key-value pairs, records, sections, Markdown, or
|
|
14
|
+
business fields, and never modify or transcode the source.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "tiwater.text-mcp-input/text_inspect/v1",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"properties": {
|
|
6
|
+
"input": {
|
|
7
|
+
"type": "string",
|
|
8
|
+
"minLength": 1,
|
|
9
|
+
"x-tiwater-file-role": "read"
|
|
10
|
+
},
|
|
11
|
+
"returnContent": {
|
|
12
|
+
"type": "boolean",
|
|
13
|
+
"description": "Return the complete bounded inspection directly."
|
|
14
|
+
},
|
|
15
|
+
"output": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"minLength": 1,
|
|
18
|
+
"description": "New immutable JSON artifact path for the complete inspection.",
|
|
19
|
+
"x-tiwater-file-role": "write",
|
|
20
|
+
"x-tiwater-file-effect": false
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"required": ["input", "returnContent", "output"],
|
|
24
|
+
"additionalProperties": false
|
|
25
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "tiwater.text-mcp-input/text_read_lines/v1",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"properties": {
|
|
6
|
+
"input": {
|
|
7
|
+
"type": "string",
|
|
8
|
+
"minLength": 1,
|
|
9
|
+
"x-tiwater-file-role": "read"
|
|
10
|
+
},
|
|
11
|
+
"offset": {
|
|
12
|
+
"type": "integer",
|
|
13
|
+
"minimum": 0,
|
|
14
|
+
"maximum": 9007199254740991,
|
|
15
|
+
"description": "Zero-based line offset in the exact current source revision."
|
|
16
|
+
},
|
|
17
|
+
"limit": {
|
|
18
|
+
"type": "integer",
|
|
19
|
+
"minimum": 1,
|
|
20
|
+
"maximum": 200,
|
|
21
|
+
"description": "Maximum number of lines in this bounded page."
|
|
22
|
+
},
|
|
23
|
+
"returnContent": {
|
|
24
|
+
"type": "boolean",
|
|
25
|
+
"description": "Return this selected line page directly when it fits the response limit. May be combined with output."
|
|
26
|
+
},
|
|
27
|
+
"output": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"minLength": 1,
|
|
30
|
+
"description": "Optional immutable JSON artifact path for this selected line page. May be combined with returnContent.",
|
|
31
|
+
"x-tiwater-file-role": "write"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"required": ["input", "offset", "limit", "returnContent"],
|
|
35
|
+
"additionalProperties": false
|
|
36
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "tiwater.text-provider-contract-manifest/v1",
|
|
3
|
+
"provider": {
|
|
4
|
+
"id": "@tiwater/office-mcp",
|
|
5
|
+
"version": "0.21.31"
|
|
6
|
+
},
|
|
7
|
+
"tools": [
|
|
8
|
+
{
|
|
9
|
+
"name": "text_inspect",
|
|
10
|
+
"providerContract": {
|
|
11
|
+
"source": "servers/text/provider-contracts/text_inspect.schema.json",
|
|
12
|
+
"sha256": "7a4af90303d2a0abce66a2e77f83838dbb2ea925edf98b04a80723b92b7f5ec0"
|
|
13
|
+
},
|
|
14
|
+
"inputContract": {
|
|
15
|
+
"path": "text/contracts/text_inspect.schema.json",
|
|
16
|
+
"sha256": "7a4af90303d2a0abce66a2e77f83838dbb2ea925edf98b04a80723b92b7f5ec0"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "text_read_lines",
|
|
21
|
+
"providerContract": {
|
|
22
|
+
"source": "servers/text/provider-contracts/text_read_lines.schema.json",
|
|
23
|
+
"sha256": "0f9202f0b45bbd35b3ba4bebec7776da41f2c8668c7cdece068c44102f518bb6"
|
|
24
|
+
},
|
|
25
|
+
"inputContract": {
|
|
26
|
+
"path": "text/contracts/text_read_lines.schema.json",
|
|
27
|
+
"sha256": "0f9202f0b45bbd35b3ba4bebec7776da41f2c8668c7cdece068c44102f518bb6"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
package/text/index.mjs
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import * as z from 'zod/v4';
|
|
6
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
7
|
+
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
8
|
+
|
|
9
|
+
import { createToolResult } from '../_shared/tool-runtime.mjs';
|
|
10
|
+
import { deliverLargeJsonResult } from '../_shared/large-json-result.mjs';
|
|
11
|
+
import { withOutputWriteLock } from '../_shared/output-write-lock.mjs';
|
|
12
|
+
import { evidenceRoleMetadata } from '../_shared/evidence-role.mjs';
|
|
13
|
+
import { inspectText, readTextLines } from './observation.mjs';
|
|
14
|
+
|
|
15
|
+
const packageMetadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
16
|
+
const contractManifest = JSON.parse(await readFile(
|
|
17
|
+
new URL('./contracts/tiwater-text-provider-contract-manifest-v1.json', import.meta.url),
|
|
18
|
+
'utf8',
|
|
19
|
+
));
|
|
20
|
+
if (contractManifest.schema !== 'tiwater.text-provider-contract-manifest/v1'
|
|
21
|
+
|| contractManifest.provider?.id !== packageMetadata.name
|
|
22
|
+
|| contractManifest.provider?.version !== packageMetadata.version) {
|
|
23
|
+
throw new Error('Text MCP input contract manifest does not match the installed distribution');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const inputContracts = new Map(await Promise.all(contractManifest.tools.map(async entry => {
|
|
27
|
+
const bytes = await readFile(new URL(`./contracts/${entry.name}.schema.json`, import.meta.url));
|
|
28
|
+
const hash = createHash('sha256').update(bytes).digest('hex');
|
|
29
|
+
if (hash !== entry.inputContract.sha256) {
|
|
30
|
+
throw new Error(`Text MCP input contract hash mismatch: ${entry.name}`);
|
|
31
|
+
}
|
|
32
|
+
return [entry.name, z.fromJSONSchema(JSON.parse(bytes.toString('utf8')))];
|
|
33
|
+
})));
|
|
34
|
+
|
|
35
|
+
const openingLineLimit = 8;
|
|
36
|
+
const artifact = z.object({
|
|
37
|
+
path: z.string(),
|
|
38
|
+
sha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
39
|
+
bytes: z.number().int().nonnegative(),
|
|
40
|
+
}).strict();
|
|
41
|
+
const runtimeIdentity = z.object({ command: z.literal('tiwater-text-mcp'), cwd: z.string() }).strict();
|
|
42
|
+
const decoding = z.object({
|
|
43
|
+
status: z.literal('lossless'),
|
|
44
|
+
encoding: z.enum(['utf-8', 'utf-16le', 'utf-16be']),
|
|
45
|
+
bom: z.enum(['none', 'utf-8', 'utf-16le', 'utf-16be']),
|
|
46
|
+
}).strict();
|
|
47
|
+
const lineIdentity = z.object({
|
|
48
|
+
sourceSha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
49
|
+
index: z.number().int().nonnegative(),
|
|
50
|
+
}).strict();
|
|
51
|
+
const terminator = z.enum(['none', 'lf', 'crlf', 'cr']);
|
|
52
|
+
const openingLine = z.object({
|
|
53
|
+
identity: lineIdentity,
|
|
54
|
+
textPreview: z.string(),
|
|
55
|
+
textLength: z.number().int().nonnegative(),
|
|
56
|
+
terminator,
|
|
57
|
+
}).strict();
|
|
58
|
+
const inspectIdentity = z.object({
|
|
59
|
+
source: artifact,
|
|
60
|
+
extension: z.string(),
|
|
61
|
+
decoding,
|
|
62
|
+
lineCount: z.number().int().nonnegative(),
|
|
63
|
+
openingLines: z.array(openingLine).max(openingLineLimit),
|
|
64
|
+
}).strict();
|
|
65
|
+
const linePageReceipt = z.object({
|
|
66
|
+
schema: z.literal('tiwater.text-line-page-receipt/v1'),
|
|
67
|
+
totalLineCount: z.number().int().nonnegative(),
|
|
68
|
+
returnedLineCount: z.number().int().nonnegative(),
|
|
69
|
+
remaining: z.number().int().nonnegative(),
|
|
70
|
+
nextOffset: z.number().int().nonnegative().nullable(),
|
|
71
|
+
}).strict();
|
|
72
|
+
const textLine = z.object({ identity: lineIdentity, text: z.string(), terminator }).strict();
|
|
73
|
+
const inspectContent = z.object({
|
|
74
|
+
schema: z.literal('tiwater.text-inspection/v1'),
|
|
75
|
+
source: artifact,
|
|
76
|
+
extension: z.string(),
|
|
77
|
+
decoding,
|
|
78
|
+
lineCount: z.number().int().nonnegative(),
|
|
79
|
+
openingLines: z.array(openingLine).max(openingLineLimit),
|
|
80
|
+
}).strict();
|
|
81
|
+
const linePage = z.object({
|
|
82
|
+
schema: z.literal('tiwater.text-line-page/v1'),
|
|
83
|
+
source: artifact,
|
|
84
|
+
extension: z.string(),
|
|
85
|
+
decoding,
|
|
86
|
+
receipt: linePageReceipt,
|
|
87
|
+
lines: z.array(textLine).max(200),
|
|
88
|
+
}).strict();
|
|
89
|
+
|
|
90
|
+
function largeResultOutput(contentSchema) {
|
|
91
|
+
return z.object({
|
|
92
|
+
tool: z.string(),
|
|
93
|
+
runtime: runtimeIdentity,
|
|
94
|
+
sources: z.array(artifact).min(1).max(1),
|
|
95
|
+
returnContent: z.boolean(),
|
|
96
|
+
artifact: artifact.nullable(),
|
|
97
|
+
receipt: z.object({
|
|
98
|
+
contentBytes: z.number().int().nonnegative(),
|
|
99
|
+
contentReturned: z.boolean(),
|
|
100
|
+
contentWritten: z.boolean(),
|
|
101
|
+
}).strict(),
|
|
102
|
+
content: contentSchema.optional(),
|
|
103
|
+
}).strict();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const definitions = [
|
|
107
|
+
{
|
|
108
|
+
name: 'text_inspect',
|
|
109
|
+
evidenceRole: 'document-observation',
|
|
110
|
+
description: 'Inspect one exact supported plain-text revision. Return its byte identity, lossless encoding and BOM facts, line count, and at most eight opening line identities while retaining the complete bounded inspection at output. It does not parse fields, records, key-value pairs, sections, or markup.',
|
|
111
|
+
outputSchema: largeResultOutput(inspectContent).extend({ identity: inspectIdentity }).strict(),
|
|
112
|
+
handler: textInspect,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'text_read_lines',
|
|
116
|
+
description: 'Read one explicit zero-based line page from one exact supported plain-text revision. The receipt reports remaining lines and nextOffset; continue only when another line is needed. Set returnContent true to return the selected page when it fits the response limit. Provide output to retain the same complete page. These channels are independent and may be used together; at least one is required. Lines retain their exact decoded text and terminator; the provider does not interpret fields, records, key-value pairs, sections, Markdown, or business meaning.',
|
|
117
|
+
outputSchema: largeResultOutput(linePage).extend({ summary: linePageReceipt }).strict(),
|
|
118
|
+
handler: textReadLines,
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
function buildServer() {
|
|
123
|
+
const server = new McpServer(
|
|
124
|
+
{ name: 'tiwater-text', version: packageMetadata.version },
|
|
125
|
+
{ instructions: 'Observe only exact supported plain-text bytes and explicit zero-based line pages. A read-only output path is an immutable artifact identity: an identical request may replay it; every different request uses a different path. Callers own all interpretation and business meaning.' },
|
|
126
|
+
);
|
|
127
|
+
for (const definition of definitions) {
|
|
128
|
+
const inputSchema = inputContracts.get(definition.name);
|
|
129
|
+
if (!inputSchema) throw new Error(`Missing provider-owned Text MCP input contract: ${definition.name}`);
|
|
130
|
+
server.registerTool(
|
|
131
|
+
definition.name,
|
|
132
|
+
{
|
|
133
|
+
description: definition.description,
|
|
134
|
+
inputSchema,
|
|
135
|
+
outputSchema: definition.outputSchema,
|
|
136
|
+
annotations: {
|
|
137
|
+
readOnlyHint: true,
|
|
138
|
+
idempotentHint: true,
|
|
139
|
+
destructiveHint: false,
|
|
140
|
+
openWorldHint: false,
|
|
141
|
+
},
|
|
142
|
+
...(definition.evidenceRole ? { _meta: evidenceRoleMetadata(definition.evidenceRole) } : {}),
|
|
143
|
+
},
|
|
144
|
+
async args => {
|
|
145
|
+
const payload = typeof args.output === 'string'
|
|
146
|
+
? await withOutputWriteLock(args.output, () => definition.handler(args))
|
|
147
|
+
: await definition.handler(args);
|
|
148
|
+
return createToolResult(payload);
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return server;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function textInspect(args) {
|
|
156
|
+
const observation = await inspectText(args.input);
|
|
157
|
+
const delivered = await deliverLargeJsonResult({
|
|
158
|
+
tool: 'text_inspect',
|
|
159
|
+
args,
|
|
160
|
+
runtime: runtime(),
|
|
161
|
+
payload: observation.payload,
|
|
162
|
+
sourcePaths: [observation.input],
|
|
163
|
+
});
|
|
164
|
+
return { ...delivered, identity: observation.identity };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function textReadLines(args) {
|
|
168
|
+
const observation = await readTextLines(args.input, args.offset, args.limit);
|
|
169
|
+
const delivered = await deliverLargeJsonResult({
|
|
170
|
+
tool: 'text_read_lines',
|
|
171
|
+
args,
|
|
172
|
+
runtime: runtime(),
|
|
173
|
+
payload: observation.payload,
|
|
174
|
+
sourcePaths: [observation.input],
|
|
175
|
+
});
|
|
176
|
+
return { ...delivered, summary: observation.receipt };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function runtime() {
|
|
180
|
+
return { command: 'tiwater-text-mcp', cwd: process.cwd() };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
serveStdio(buildServer);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { fileArtifact } from '../_shared/large-json-result.mjs';
|
|
5
|
+
|
|
6
|
+
const supportedExtensions = new Set(['.txt', '.text', '.log', '.csv', '.tsv', '.md', '.markdown']);
|
|
7
|
+
const openingLineLimit = 8;
|
|
8
|
+
const openingTextLimit = 160;
|
|
9
|
+
|
|
10
|
+
export async function inspectText(inputValue) {
|
|
11
|
+
const observation = await observeText(inputValue);
|
|
12
|
+
const source = await fileArtifact(observation.input);
|
|
13
|
+
const openingLines = observation.lines.slice(0, openingLineLimit).map(line => ({
|
|
14
|
+
identity: { sourceSha256: source.sha256, index: line.index },
|
|
15
|
+
textPreview: preview(line.text, openingTextLimit),
|
|
16
|
+
textLength: [...line.text].length,
|
|
17
|
+
terminator: line.terminator,
|
|
18
|
+
}));
|
|
19
|
+
const identity = {
|
|
20
|
+
source,
|
|
21
|
+
extension: observation.extension,
|
|
22
|
+
decoding: observation.decoding,
|
|
23
|
+
lineCount: observation.lines.length,
|
|
24
|
+
openingLines,
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
input: observation.input,
|
|
28
|
+
identity,
|
|
29
|
+
payload: { schema: 'tiwater.text-inspection/v1', ...identity },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function readTextLines(inputValue, requestedOffset, requestedLimit) {
|
|
34
|
+
if (!Number.isSafeInteger(requestedOffset) || requestedOffset < 0) {
|
|
35
|
+
throw Object.assign(new Error('offset must be a non-negative safe integer'), { code: -32602 });
|
|
36
|
+
}
|
|
37
|
+
if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1 || requestedLimit > 200) {
|
|
38
|
+
throw Object.assign(new Error('limit must be a safe integer from 1 through 200'), { code: -32602 });
|
|
39
|
+
}
|
|
40
|
+
const observation = await observeText(inputValue);
|
|
41
|
+
const source = await fileArtifact(observation.input);
|
|
42
|
+
const offset = Math.min(requestedOffset, observation.lines.length);
|
|
43
|
+
const selected = observation.lines.slice(offset, offset + requestedLimit);
|
|
44
|
+
const nextOffset = offset + selected.length < observation.lines.length
|
|
45
|
+
? offset + selected.length
|
|
46
|
+
: null;
|
|
47
|
+
const receipt = {
|
|
48
|
+
schema: 'tiwater.text-line-page-receipt/v1',
|
|
49
|
+
totalLineCount: observation.lines.length,
|
|
50
|
+
returnedLineCount: selected.length,
|
|
51
|
+
remaining: observation.lines.length - offset - selected.length,
|
|
52
|
+
nextOffset,
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
input: observation.input,
|
|
56
|
+
receipt,
|
|
57
|
+
payload: {
|
|
58
|
+
schema: 'tiwater.text-line-page/v1',
|
|
59
|
+
source,
|
|
60
|
+
extension: observation.extension,
|
|
61
|
+
decoding: observation.decoding,
|
|
62
|
+
receipt,
|
|
63
|
+
lines: selected.map(line => ({
|
|
64
|
+
identity: { sourceSha256: source.sha256, index: line.index },
|
|
65
|
+
text: line.text,
|
|
66
|
+
terminator: line.terminator,
|
|
67
|
+
})),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function observeText(inputValue) {
|
|
73
|
+
if (typeof inputValue !== 'string' || inputValue.trim() === '') {
|
|
74
|
+
throw Object.assign(new Error('input must be a non-empty string'), { code: -32602 });
|
|
75
|
+
}
|
|
76
|
+
const input = path.resolve(inputValue);
|
|
77
|
+
const extension = path.extname(input).toLowerCase();
|
|
78
|
+
if (!supportedExtensions.has(extension)) {
|
|
79
|
+
throw Object.assign(new Error(`unsupported-plain-text-extension:${extension || '(none)'}`), { code: -32602 });
|
|
80
|
+
}
|
|
81
|
+
const bytes = await readFile(input);
|
|
82
|
+
const decoded = decodeLosslessly(bytes);
|
|
83
|
+
rejectBinaryControls(decoded.text);
|
|
84
|
+
return { input, extension, decoding: decoded.decoding, lines: splitLines(decoded.text) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function decodeLosslessly(bytes) {
|
|
88
|
+
if (bytes.subarray(0, 3).equals(Buffer.from([0xef, 0xbb, 0xbf]))) {
|
|
89
|
+
return decodeWithRoundTrip(bytes.subarray(3), 'utf-8', 'utf-8');
|
|
90
|
+
}
|
|
91
|
+
if (bytes.subarray(0, 2).equals(Buffer.from([0xff, 0xfe]))) {
|
|
92
|
+
return decodeWithRoundTrip(bytes.subarray(2), 'utf-16le', 'utf-16le');
|
|
93
|
+
}
|
|
94
|
+
if (bytes.subarray(0, 2).equals(Buffer.from([0xfe, 0xff]))) {
|
|
95
|
+
return decodeWithRoundTrip(bytes.subarray(2), 'utf-16be', 'utf-16be');
|
|
96
|
+
}
|
|
97
|
+
return decodeWithRoundTrip(bytes, 'utf-8', 'none');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function decodeWithRoundTrip(bytes, encoding, bom) {
|
|
101
|
+
if ((encoding === 'utf-16le' || encoding === 'utf-16be') && bytes.length % 2 !== 0) {
|
|
102
|
+
throw Object.assign(new Error(`invalid-${encoding}-byte-length`), { code: -32602 });
|
|
103
|
+
}
|
|
104
|
+
let text;
|
|
105
|
+
try {
|
|
106
|
+
text = new TextDecoder(encoding, { fatal: true }).decode(bytes);
|
|
107
|
+
} catch {
|
|
108
|
+
throw Object.assign(new Error(`invalid-${encoding}-sequence`), { code: -32602 });
|
|
109
|
+
}
|
|
110
|
+
let encoded = encoding === 'utf-8' ? Buffer.from(text, 'utf8') : Buffer.from(text, 'utf16le');
|
|
111
|
+
if (encoding === 'utf-16be') encoded = swapUtf16Bytes(encoded);
|
|
112
|
+
if (!encoded.equals(bytes)) {
|
|
113
|
+
throw Object.assign(new Error(`non-lossless-${encoding}-decode`), { code: -32602 });
|
|
114
|
+
}
|
|
115
|
+
return { text, decoding: { status: 'lossless', encoding, bom } };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function swapUtf16Bytes(bytes) {
|
|
119
|
+
const swapped = Buffer.allocUnsafe(bytes.length);
|
|
120
|
+
for (let index = 0; index < bytes.length; index += 2) {
|
|
121
|
+
swapped[index] = bytes[index + 1];
|
|
122
|
+
swapped[index + 1] = bytes[index];
|
|
123
|
+
}
|
|
124
|
+
return swapped;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function rejectBinaryControls(text) {
|
|
128
|
+
const binary = [...text].some(character => {
|
|
129
|
+
const code = character.codePointAt(0);
|
|
130
|
+
return code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31)
|
|
131
|
+
|| (code >= 127 && code <= 159);
|
|
132
|
+
});
|
|
133
|
+
if (binary) throw Object.assign(new Error('binary-control-content-is-not-plain-text'), { code: -32602 });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function splitLines(text) {
|
|
137
|
+
if (text.length === 0) return [];
|
|
138
|
+
const lines = [];
|
|
139
|
+
let start = 0;
|
|
140
|
+
while (start < text.length) {
|
|
141
|
+
let end = start;
|
|
142
|
+
while (end < text.length && text[end] !== '\r' && text[end] !== '\n') end++;
|
|
143
|
+
let terminator = 'none';
|
|
144
|
+
let next = end;
|
|
145
|
+
if (end < text.length) {
|
|
146
|
+
if (text[end] === '\r' && text[end + 1] === '\n') {
|
|
147
|
+
terminator = 'crlf';
|
|
148
|
+
next = end + 2;
|
|
149
|
+
} else {
|
|
150
|
+
terminator = text[end] === '\r' ? 'cr' : 'lf';
|
|
151
|
+
next = end + 1;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
lines.push({ index: lines.length, text: text.slice(start, end), terminator });
|
|
155
|
+
start = next;
|
|
156
|
+
}
|
|
157
|
+
return lines;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function preview(text, limit) {
|
|
161
|
+
return [...text].slice(0, limit).join('');
|
|
162
|
+
}
|