tgo-wiki 0.1.0

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 (68) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +255 -0
  3. package/docs/mcp-usage.md +631 -0
  4. package/docs/v0-acceptance.md +105 -0
  5. package/docs/v0-delivery-checklist.md +57 -0
  6. package/docs/v1-acceptance.md +39 -0
  7. package/docs/v2-acceptance.md +165 -0
  8. package/package.json +69 -0
  9. package/packages/core/src/config/config-loader.ts +109 -0
  10. package/packages/core/src/config/defaults.ts +74 -0
  11. package/packages/core/src/config/workspace-resolver.ts +40 -0
  12. package/packages/core/src/documents/command-document-parser.ts +206 -0
  13. package/packages/core/src/documents/document-id.ts +26 -0
  14. package/packages/core/src/documents/document-parser-registry.ts +126 -0
  15. package/packages/core/src/documents/document-service.ts +656 -0
  16. package/packages/core/src/documents/document-store.ts +132 -0
  17. package/packages/core/src/documents/document-types.ts +33 -0
  18. package/packages/core/src/documents/pdf-text-parser.ts +35 -0
  19. package/packages/core/src/documents/text-markdown-parser.ts +50 -0
  20. package/packages/core/src/errors.ts +46 -0
  21. package/packages/core/src/git/git-service.ts +68 -0
  22. package/packages/core/src/index.ts +38 -0
  23. package/packages/core/src/markdown/markdown-scanner.ts +90 -0
  24. package/packages/core/src/permissions/permission-service.ts +50 -0
  25. package/packages/core/src/publish/publish-service.ts +142 -0
  26. package/packages/core/src/result.ts +13 -0
  27. package/packages/core/src/services/session-workflow-service.ts +493 -0
  28. package/packages/core/src/services/wiki-service.ts +119 -0
  29. package/packages/core/src/services/workspace-service.ts +223 -0
  30. package/packages/core/src/session/session-id.ts +14 -0
  31. package/packages/core/src/session/session-service.ts +77 -0
  32. package/packages/core/src/session/session-store.ts +91 -0
  33. package/packages/core/src/session/session-types.ts +17 -0
  34. package/packages/core/src/sources/source-id.ts +19 -0
  35. package/packages/core/src/sources/source-paths.ts +15 -0
  36. package/packages/core/src/sources/source-service.ts +416 -0
  37. package/packages/core/src/sources/source-types.ts +77 -0
  38. package/packages/core/src/sources/source-validator.ts +132 -0
  39. package/packages/core/src/sources/source-writer.ts +419 -0
  40. package/packages/core/src/validation/frontmatter-validator.ts +128 -0
  41. package/packages/core/src/validation/link-validator.ts +55 -0
  42. package/packages/core/src/validation/path-validator.ts +65 -0
  43. package/packages/core/src/validation/source-reference-validator.ts +191 -0
  44. package/packages/core/src/validation/validation-service.ts +106 -0
  45. package/packages/core/src/vfs/vfs-command-parser.ts +69 -0
  46. package/packages/core/src/vfs/vfs-service.ts +498 -0
  47. package/packages/core/src/web/html-to-markdown.ts +144 -0
  48. package/packages/core/src/web/static-web-fetcher.ts +537 -0
  49. package/packages/core/src/web/web-id.ts +26 -0
  50. package/packages/core/src/web/web-ingestion-service.ts +335 -0
  51. package/packages/core/src/web/web-paths.ts +6 -0
  52. package/packages/core/src/web/web-types.ts +33 -0
  53. package/packages/server/src/cli.ts +56 -0
  54. package/packages/server/src/context.ts +7 -0
  55. package/packages/server/src/index.ts +2 -0
  56. package/packages/server/src/mcp-server.ts +111 -0
  57. package/packages/server/src/schemas/documents.ts +17 -0
  58. package/packages/server/src/schemas/read.ts +16 -0
  59. package/packages/server/src/schemas/session.ts +31 -0
  60. package/packages/server/src/schemas/sources.ts +12 -0
  61. package/packages/server/src/schemas/web.ts +23 -0
  62. package/packages/server/src/tools/document-tools.ts +46 -0
  63. package/packages/server/src/tools/publish-tools.ts +33 -0
  64. package/packages/server/src/tools/read-tools.ts +52 -0
  65. package/packages/server/src/tools/response.ts +24 -0
  66. package/packages/server/src/tools/session-tools.ts +100 -0
  67. package/packages/server/src/tools/source-tools.ts +32 -0
  68. package/packages/server/src/tools/web-tools.ts +26 -0
@@ -0,0 +1,206 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { WikiError } from "../errors.js";
3
+ import type { DocumentParseInput, DocumentParseOutput, DocumentParser } from "./document-parser-registry.js";
4
+
5
+ const defaultTimeoutMs = 30_000;
6
+ const outputSnippetLimit = 4_096;
7
+
8
+ export type CommandDocumentParserOptions = {
9
+ name: string;
10
+ command: string;
11
+ supportedMimeTypes: string[];
12
+ version?: string;
13
+ timeoutMs?: number;
14
+ };
15
+
16
+ export class CommandDocumentParser implements DocumentParser {
17
+ readonly name: string;
18
+ readonly version: string;
19
+ readonly supportedMimeTypes: readonly string[];
20
+ private readonly command: string;
21
+ private readonly executable: string;
22
+ private readonly args: string[];
23
+ private readonly timeoutMs: number;
24
+
25
+ constructor(options: CommandDocumentParserOptions) {
26
+ this.name = options.name;
27
+ this.command = options.command;
28
+ this.version = options.version ?? "command";
29
+ this.supportedMimeTypes = options.supportedMimeTypes;
30
+ this.timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
31
+ const [executable, ...args] = splitCommandLine(options.command);
32
+ if (!executable) {
33
+ throw new WikiError("parser_not_supported", `Invalid parser command: ${options.name}`, { parser: options.name });
34
+ }
35
+ this.executable = executable;
36
+ this.args = args;
37
+ }
38
+
39
+ async parse(input: DocumentParseInput): Promise<DocumentParseOutput> {
40
+ const result = await runParserCommand({
41
+ executable: this.executable,
42
+ args: this.args,
43
+ input,
44
+ timeoutMs: this.timeoutMs
45
+ });
46
+ const stdout = stripUtf8Bom(result.stdout.toString("utf8"));
47
+ const stderr = result.stderr.toString("utf8");
48
+
49
+ if (stdout.length === 0) {
50
+ throw new WikiError("parse_failed", `Command parser produced empty stdout: ${this.name}`, { parser: this.name });
51
+ }
52
+
53
+ const markdown = ensureFinalNewline(stdout);
54
+
55
+ return {
56
+ markdown,
57
+ assets: [],
58
+ warnings: stderr.length > 0 ? [truncate(stderr.trimEnd())] : [],
59
+ metadata: {
60
+ command: this.command,
61
+ stdoutBytes: Buffer.byteLength(markdown),
62
+ stderrBytes: result.stderr.length
63
+ }
64
+ };
65
+ }
66
+ }
67
+
68
+ export function splitCommandLine(command: string): string[] {
69
+ const tokens: string[] = [];
70
+ let current = "";
71
+ let quote: "'" | '"' | undefined;
72
+
73
+ for (let i = 0; i < command.length; i += 1) {
74
+ const char = command[i];
75
+ if (quote) {
76
+ if (char === quote) {
77
+ quote = undefined;
78
+ } else {
79
+ current += char;
80
+ }
81
+ continue;
82
+ }
83
+
84
+ if (char === "'" || char === '"') {
85
+ quote = char;
86
+ continue;
87
+ }
88
+
89
+ if (/\s/.test(char)) {
90
+ if (current.length > 0) {
91
+ tokens.push(current);
92
+ current = "";
93
+ }
94
+ continue;
95
+ }
96
+
97
+ current += char;
98
+ }
99
+
100
+ if (quote) {
101
+ throw new WikiError("parser_not_supported", "Invalid parser command: unterminated quote");
102
+ }
103
+
104
+ if (current.length > 0) {
105
+ tokens.push(current);
106
+ }
107
+
108
+ return tokens;
109
+ }
110
+
111
+ type RunParserCommandInput = {
112
+ executable: string;
113
+ args: string[];
114
+ input: DocumentParseInput;
115
+ timeoutMs: number;
116
+ };
117
+
118
+ type RunParserCommandOutput = {
119
+ stdout: Buffer;
120
+ stderr: Buffer;
121
+ };
122
+
123
+ function runParserCommand(options: RunParserCommandInput): Promise<RunParserCommandOutput> {
124
+ return new Promise((resolve, reject) => {
125
+ let child: ChildProcessWithoutNullStreams;
126
+ try {
127
+ child = spawn(options.executable, options.args, {
128
+ shell: false,
129
+ stdio: ["pipe", "pipe", "pipe"],
130
+ env: {
131
+ ...process.env,
132
+ TGO_DOCUMENT_ID: options.input.documentId,
133
+ TGO_FILE_NAME: options.input.fileName,
134
+ TGO_MIME_TYPE: options.input.mimeType,
135
+ TGO_PARSED_AT: options.input.parsedAt
136
+ }
137
+ }) as ChildProcessWithoutNullStreams;
138
+ } catch (error) {
139
+ reject(new WikiError("parse_failed", error instanceof Error ? error.message : String(error)));
140
+ return;
141
+ }
142
+ const stdout: Buffer[] = [];
143
+ const stderr: Buffer[] = [];
144
+ let timedOut = false;
145
+ let settled = false;
146
+
147
+ const timeout = setTimeout(() => {
148
+ timedOut = true;
149
+ child.kill();
150
+ }, options.timeoutMs);
151
+
152
+ const rejectOnce = (error: WikiError) => {
153
+ if (settled) {
154
+ return;
155
+ }
156
+
157
+ settled = true;
158
+ clearTimeout(timeout);
159
+ reject(error);
160
+ };
161
+
162
+ child.stdout.on("data", chunk => stdout.push(Buffer.from(chunk)));
163
+ child.stderr.on("data", chunk => stderr.push(Buffer.from(chunk)));
164
+ child.on("error", error => {
165
+ rejectOnce(new WikiError("parse_failed", error.message));
166
+ });
167
+ child.on("close", code => {
168
+ const stderrBuffer = Buffer.concat(stderr);
169
+ if (timedOut) {
170
+ rejectOnce(new WikiError("parse_failed", "Command parser timed out", { stderr: truncate(stderrBuffer.toString("utf8")) }));
171
+ return;
172
+ }
173
+ if (code !== 0) {
174
+ rejectOnce(
175
+ new WikiError("parse_failed", `Command parser exited with code ${code}`, {
176
+ exitCode: code,
177
+ stderr: truncate(stderrBuffer.toString("utf8"))
178
+ })
179
+ );
180
+ return;
181
+ }
182
+
183
+ if (settled) {
184
+ return;
185
+ }
186
+
187
+ settled = true;
188
+ clearTimeout(timeout);
189
+ resolve({ stdout: Buffer.concat(stdout), stderr: stderrBuffer });
190
+ });
191
+
192
+ child.stdin.end(options.input.content);
193
+ });
194
+ }
195
+
196
+ function ensureFinalNewline(value: string): string {
197
+ return value.endsWith("\n") ? value : `${value}\n`;
198
+ }
199
+
200
+ function stripUtf8Bom(value: string): string {
201
+ return value.startsWith("\uFEFF") ? value.slice(1) : value;
202
+ }
203
+
204
+ function truncate(value: string): string {
205
+ return value.length > outputSnippetLimit ? `${value.slice(0, outputSnippetLimit)}...` : value;
206
+ }
@@ -0,0 +1,26 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import path from "node:path";
3
+ import { WikiError } from "../errors.js";
4
+
5
+ const documentIdPattern = /^[a-zA-Z0-9._-]+$/;
6
+
7
+ export function generateDocumentId(now = new Date()): string {
8
+ const date = now.toISOString().slice(0, 10).replaceAll("-", "");
9
+ const suffix = randomBytes(6).toString("hex");
10
+ return `doc_${date}_${suffix}`;
11
+ }
12
+
13
+ export function assertValidDocumentId(documentId: string): void {
14
+ if (
15
+ documentId.length === 0 ||
16
+ !documentIdPattern.test(documentId) ||
17
+ documentId.includes("/") ||
18
+ documentId.includes("\\") ||
19
+ documentId === "." ||
20
+ documentId === ".." ||
21
+ path.isAbsolute(documentId) ||
22
+ path.win32.isAbsolute(documentId)
23
+ ) {
24
+ throw new WikiError("invalid_path", `Invalid document id: ${documentId}`);
25
+ }
26
+ }
@@ -0,0 +1,126 @@
1
+ import type { DocumentParserConfig } from "../config/defaults.js";
2
+ import { WikiError } from "../errors.js";
3
+ import { CommandDocumentParser } from "./command-document-parser.js";
4
+ import { PdfTextParser } from "./pdf-text-parser.js";
5
+ import { TextMarkdownParser } from "./text-markdown-parser.js";
6
+
7
+ export type DocumentParserConfigShape = {
8
+ defaultParser: string;
9
+ parsers: Record<string, DocumentParserConfig>;
10
+ };
11
+
12
+ export type DocumentParserAsset = {
13
+ path: string;
14
+ content: Buffer | Uint8Array | string;
15
+ };
16
+
17
+ export type DocumentParseInput = {
18
+ documentId: string;
19
+ fileName: string;
20
+ mimeType: string;
21
+ content: Buffer;
22
+ parsedAt: string;
23
+ };
24
+
25
+ export type DocumentParseOutput = {
26
+ markdown: string;
27
+ assets: DocumentParserAsset[];
28
+ warnings: string[];
29
+ metadata: Record<string, unknown>;
30
+ };
31
+
32
+ export type DocumentParser = {
33
+ name: string;
34
+ version: string;
35
+ supportedMimeTypes: readonly string[];
36
+ parse(input: DocumentParseInput): Promise<DocumentParseOutput>;
37
+ };
38
+
39
+ export class DocumentParserRegistry {
40
+ private readonly parsers: Map<string, DocumentParser>;
41
+
42
+ constructor(
43
+ private readonly config: DocumentParserConfigShape,
44
+ parsers: Iterable<DocumentParser> = [new PdfTextParser(), new TextMarkdownParser()]
45
+ ) {
46
+ this.parsers = new Map(Array.from(parsers, parser => [parser.name, parser]));
47
+
48
+ for (const [parserName, parserConfig] of Object.entries(config.parsers)) {
49
+ if (this.parsers.has(parserName) || parserConfig.enabled !== true || !parserConfig.command) {
50
+ continue;
51
+ }
52
+
53
+ if (!parserConfig.supportedMimeTypes || parserConfig.supportedMimeTypes.length === 0) {
54
+ continue;
55
+ }
56
+
57
+ this.parsers.set(
58
+ parserName,
59
+ new CommandDocumentParser({
60
+ name: parserName,
61
+ command: parserConfig.command,
62
+ supportedMimeTypes: parserConfig.supportedMimeTypes,
63
+ version: parserConfig.version,
64
+ timeoutMs: parserConfig.timeoutMs
65
+ })
66
+ );
67
+ }
68
+ }
69
+
70
+ resolve(requestedParser: "auto" | string, mimeType: string): DocumentParser {
71
+ const parserName = requestedParser === "auto" ? this.resolveAutoParserName(mimeType) : requestedParser;
72
+ const configured = this.config.parsers[parserName];
73
+
74
+ if (!configured) {
75
+ throw new WikiError("parser_not_supported", `Document parser is not supported: ${parserName}`, {
76
+ parser: parserName
77
+ });
78
+ }
79
+
80
+ if (!configured.enabled) {
81
+ throw new WikiError("parser_not_supported", `Document parser is disabled: ${parserName}`, {
82
+ parser: parserName
83
+ });
84
+ }
85
+
86
+ const parser = this.parsers.get(parserName);
87
+ if (!parser) {
88
+ throw new WikiError("parser_not_supported", `Document parser is not supported: ${parserName}`, {
89
+ parser: parserName
90
+ });
91
+ }
92
+
93
+ if (!parser.supportedMimeTypes.includes(mimeType)) {
94
+ throw new WikiError("parser_not_supported", `Document parser does not support MIME type: ${mimeType}`, {
95
+ parser: parserName,
96
+ mimeType
97
+ });
98
+ }
99
+
100
+ return parser;
101
+ }
102
+
103
+ private resolveAutoParserName(mimeType: string): string {
104
+ const defaultParser = this.parserIfEnabled(this.config.defaultParser);
105
+ if (defaultParser?.supportedMimeTypes.includes(mimeType)) {
106
+ return defaultParser.name;
107
+ }
108
+
109
+ for (const parserName of Object.keys(this.config.parsers)) {
110
+ const parser = this.parserIfEnabled(parserName);
111
+ if (parser?.supportedMimeTypes.includes(mimeType)) {
112
+ return parser.name;
113
+ }
114
+ }
115
+
116
+ return this.config.defaultParser;
117
+ }
118
+
119
+ private parserIfEnabled(parserName: string): DocumentParser | undefined {
120
+ if (this.config.parsers[parserName]?.enabled !== true) {
121
+ return undefined;
122
+ }
123
+
124
+ return this.parsers.get(parserName);
125
+ }
126
+ }