pi-harness-runtime 1.1.326 → 1.1.328
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/package.json +1 -1
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/agent-policy/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/package.json +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/codex-watcher/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/durable-review/package.json +1 -1
- package/packages/e2e-tester/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/jev-judge/package.json +1 -1
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/paperclip-integration/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/qdrant-skills/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/skills/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/telegram/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/trajectory/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/webmcp-tester/package.json +1 -1
- package/packages/wiki/dist/index.d.ts +3 -1
- package/packages/wiki/dist/index.d.ts.map +1 -1
- package/packages/wiki/dist/index.js +56 -23
- package/packages/wiki/dist/index.js.map +1 -1
- package/packages/wiki/dist/storage.d.ts +37 -7
- package/packages/wiki/dist/storage.d.ts.map +1 -1
- package/packages/wiki/dist/storage.js +206 -53
- package/packages/wiki/dist/storage.js.map +1 -1
- package/packages/wiki/package.json +1 -1
- package/packages/wiki/src/index.ts +68 -26
- package/packages/wiki/src/storage.ts +224 -49
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
-
import { join, resolve
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
10
|
import {
|
|
11
11
|
WikiStorage,
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
computeHash,
|
|
14
14
|
now,
|
|
15
15
|
parseFrontmatter,
|
|
16
|
-
combinePage,
|
|
17
16
|
} from "./storage.js";
|
|
18
17
|
import type {
|
|
19
18
|
WikiConfig,
|
|
@@ -43,12 +42,16 @@ export function isWikiEnabled(): boolean {
|
|
|
43
42
|
}
|
|
44
43
|
|
|
45
44
|
/**
|
|
46
|
-
* Require wiki to be enabled
|
|
45
|
+
* Require wiki to be enabled and ensure storage is initialized
|
|
47
46
|
*/
|
|
48
|
-
function requireWiki(): void {
|
|
47
|
+
function requireWiki(storage: WikiStorage): void {
|
|
49
48
|
if (!isWikiEnabled()) {
|
|
50
49
|
throw new Error("Wiki feature is disabled. Set HARNESS_KNOWLEDGE_WIKI=1 to enable.");
|
|
51
50
|
}
|
|
51
|
+
// Lazy initialization: ensure storage is ready on first use
|
|
52
|
+
if (!storage.isInitialized()) {
|
|
53
|
+
storage.initialize();
|
|
54
|
+
}
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
// ============================================================================
|
|
@@ -82,7 +85,9 @@ export class WikiManager {
|
|
|
82
85
|
* Initialize wiki structure
|
|
83
86
|
*/
|
|
84
87
|
initialize(): WikiInitResult {
|
|
85
|
-
|
|
88
|
+
// Initialize storage before checking readiness
|
|
89
|
+
this.storage.initialize();
|
|
90
|
+
requireWiki(this.storage);
|
|
86
91
|
return this.storage.initialize();
|
|
87
92
|
}
|
|
88
93
|
|
|
@@ -99,12 +104,13 @@ export class WikiManager {
|
|
|
99
104
|
|
|
100
105
|
/**
|
|
101
106
|
* Ingest a source file and create snapshot
|
|
107
|
+
* Returns existing snapshot if same requestId was already processed
|
|
102
108
|
*/
|
|
103
109
|
ingestSource(
|
|
104
110
|
sourcePath: string,
|
|
105
111
|
requestId?: string
|
|
106
|
-
): { snapshot: SourceSnapshot; operation: WikiOperation } {
|
|
107
|
-
requireWiki();
|
|
112
|
+
): { snapshot: SourceSnapshot; operation: WikiOperation; existing: boolean } {
|
|
113
|
+
requireWiki(this.storage);
|
|
108
114
|
|
|
109
115
|
// Validate source path
|
|
110
116
|
const resolvedPath = resolve(sourcePath);
|
|
@@ -112,6 +118,30 @@ export class WikiManager {
|
|
|
112
118
|
throw new Error(`Source file not found: ${sourcePath}`);
|
|
113
119
|
}
|
|
114
120
|
|
|
121
|
+
// Check for duplicate request
|
|
122
|
+
if (requestId) {
|
|
123
|
+
const operations = this.storage.getRecentOperations(1000);
|
|
124
|
+
const existingOp = operations.find(
|
|
125
|
+
(op) => op.requestId === requestId && op.status === "success"
|
|
126
|
+
);
|
|
127
|
+
if (existingOp) {
|
|
128
|
+
const details = existingOp.details as { snapshotId?: string; sourcePath?: string };
|
|
129
|
+
// Check if the source path matches (same requestId for different source = conflict)
|
|
130
|
+
if (details.sourcePath && details.sourcePath !== resolvedPath) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Request ID ${requestId} was already used for a different source path. ` +
|
|
133
|
+
`Original: ${details.sourcePath}, Requested: ${sourcePath}`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (details.snapshotId) {
|
|
137
|
+
const snapshot = this.storage.getSnapshot(details.snapshotId);
|
|
138
|
+
if (snapshot) {
|
|
139
|
+
return { snapshot, operation: existingOp, existing: true };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
115
145
|
// Read content
|
|
116
146
|
const content = readFileSync(resolvedPath, "utf8");
|
|
117
147
|
|
|
@@ -122,10 +152,13 @@ export class WikiManager {
|
|
|
122
152
|
// Create snapshot
|
|
123
153
|
const snapshot = this.storage.createSnapshot(resolvedPath, content, operation.id);
|
|
124
154
|
|
|
155
|
+
// Update operation with snapshot ID for future deduplication
|
|
156
|
+
this.storage.updateOperationDetails(operation.id, { snapshotId: snapshot.id });
|
|
157
|
+
|
|
125
158
|
// Complete operation
|
|
126
159
|
this.storage.completeOperation(operation.id, "success");
|
|
127
160
|
|
|
128
|
-
return { snapshot, operation };
|
|
161
|
+
return { snapshot, operation, existing: false };
|
|
129
162
|
} catch (err) {
|
|
130
163
|
const error = err instanceof Error ? err.message : String(err);
|
|
131
164
|
this.storage.completeOperation(operation.id, "failed", error);
|
|
@@ -137,7 +170,7 @@ export class WikiManager {
|
|
|
137
170
|
* Get source snapshot
|
|
138
171
|
*/
|
|
139
172
|
getSnapshot(snapshotId: string): SourceSnapshot | null {
|
|
140
|
-
requireWiki();
|
|
173
|
+
requireWiki(this.storage);
|
|
141
174
|
return this.storage.getSnapshot(snapshotId);
|
|
142
175
|
}
|
|
143
176
|
|
|
@@ -156,7 +189,7 @@ export class WikiManager {
|
|
|
156
189
|
relatedPages?: string[];
|
|
157
190
|
codeVersion?: string;
|
|
158
191
|
}): WikiPage {
|
|
159
|
-
requireWiki();
|
|
192
|
+
requireWiki(this.storage);
|
|
160
193
|
|
|
161
194
|
const id = `page-${randomUUID().slice(0, 8)}`;
|
|
162
195
|
const timestamp = now();
|
|
@@ -199,7 +232,7 @@ export class WikiManager {
|
|
|
199
232
|
verification: WikiPageFrontmatter["verification"];
|
|
200
233
|
}>
|
|
201
234
|
): WikiPage {
|
|
202
|
-
requireWiki();
|
|
235
|
+
requireWiki(this.storage);
|
|
203
236
|
|
|
204
237
|
const existing = this.storage.loadPage(pageId);
|
|
205
238
|
if (!existing) {
|
|
@@ -227,7 +260,7 @@ export class WikiManager {
|
|
|
227
260
|
* Load a wiki page
|
|
228
261
|
*/
|
|
229
262
|
loadPage(pageId: string): WikiPage | null {
|
|
230
|
-
requireWiki();
|
|
263
|
+
requireWiki(this.storage);
|
|
231
264
|
return this.storage.loadPage(pageId);
|
|
232
265
|
}
|
|
233
266
|
|
|
@@ -235,7 +268,7 @@ export class WikiManager {
|
|
|
235
268
|
* Delete a wiki page
|
|
236
269
|
*/
|
|
237
270
|
deletePage(pageId: string): void {
|
|
238
|
-
requireWiki();
|
|
271
|
+
requireWiki(this.storage);
|
|
239
272
|
this.storage.deletePage(pageId);
|
|
240
273
|
}
|
|
241
274
|
|
|
@@ -243,7 +276,7 @@ export class WikiManager {
|
|
|
243
276
|
* Get all pages
|
|
244
277
|
*/
|
|
245
278
|
getAllPages(): WikiPage[] {
|
|
246
|
-
requireWiki();
|
|
279
|
+
requireWiki(this.storage);
|
|
247
280
|
return this.storage.getAllPages();
|
|
248
281
|
}
|
|
249
282
|
|
|
@@ -255,7 +288,7 @@ export class WikiManager {
|
|
|
255
288
|
* Search wiki pages
|
|
256
289
|
*/
|
|
257
290
|
search(query: string, filters?: WikiSearchFilters, limit = 10): WikiSearchResult[] {
|
|
258
|
-
requireWiki();
|
|
291
|
+
requireWiki(this.storage);
|
|
259
292
|
return this.storage.search(query, filters, limit);
|
|
260
293
|
}
|
|
261
294
|
|
|
@@ -263,7 +296,7 @@ export class WikiManager {
|
|
|
263
296
|
* Get page index
|
|
264
297
|
*/
|
|
265
298
|
getIndex() {
|
|
266
|
-
requireWiki();
|
|
299
|
+
requireWiki(this.storage);
|
|
267
300
|
return this.storage.getIndex();
|
|
268
301
|
}
|
|
269
302
|
|
|
@@ -275,7 +308,7 @@ export class WikiManager {
|
|
|
275
308
|
* Stage a change for later application
|
|
276
309
|
*/
|
|
277
310
|
stageChange(pageId: string, expectedRevision: string, newContent: string): StagedChange {
|
|
278
|
-
requireWiki();
|
|
311
|
+
requireWiki(this.storage);
|
|
279
312
|
|
|
280
313
|
const existing = this.storage.loadPage(pageId);
|
|
281
314
|
if (!existing) {
|
|
@@ -297,7 +330,7 @@ export class WikiManager {
|
|
|
297
330
|
* Get staged change for a page
|
|
298
331
|
*/
|
|
299
332
|
getStagedChange(pageId: string): StagedChange | undefined {
|
|
300
|
-
requireWiki();
|
|
333
|
+
requireWiki(this.storage);
|
|
301
334
|
return this.storage.getStagedChange(pageId);
|
|
302
335
|
}
|
|
303
336
|
|
|
@@ -305,7 +338,7 @@ export class WikiManager {
|
|
|
305
338
|
* Clear staged change
|
|
306
339
|
*/
|
|
307
340
|
clearStagedChange(pageId: string): void {
|
|
308
|
-
requireWiki();
|
|
341
|
+
requireWiki(this.storage);
|
|
309
342
|
this.storage.clearStagedChange(pageId);
|
|
310
343
|
}
|
|
311
344
|
|
|
@@ -316,8 +349,8 @@ export class WikiManager {
|
|
|
316
349
|
/**
|
|
317
350
|
* Apply staged change with conflict detection
|
|
318
351
|
*/
|
|
319
|
-
applyChange(pageId: string,
|
|
320
|
-
requireWiki();
|
|
352
|
+
applyChange(pageId: string, _requestId?: string): WikiPage {
|
|
353
|
+
requireWiki(this.storage);
|
|
321
354
|
|
|
322
355
|
const staged = this.storage.getStagedChange(pageId);
|
|
323
356
|
if (!staged) {
|
|
@@ -329,8 +362,10 @@ export class WikiManager {
|
|
|
329
362
|
throw new Error(`Page not found: ${pageId}`);
|
|
330
363
|
}
|
|
331
364
|
|
|
332
|
-
// Check for conflicts
|
|
333
|
-
const currentRevision = computeHash(
|
|
365
|
+
// Check for conflicts - hash entire page content to detect any changes
|
|
366
|
+
const currentRevision = computeHash(
|
|
367
|
+
(existing.rawFrontmatter || "") + "\n" + existing.body
|
|
368
|
+
);
|
|
334
369
|
if (staged.expectedRevision !== currentRevision) {
|
|
335
370
|
throw new Error(
|
|
336
371
|
`Conflict: page was modified. Expected revision ${staged.expectedRevision}, found ${currentRevision}`
|
|
@@ -368,7 +403,7 @@ export class WikiManager {
|
|
|
368
403
|
* Run lint checks on wiki
|
|
369
404
|
*/
|
|
370
405
|
lint(scope?: "all" | "pages" | "links" | "freshness"): LintFinding[] {
|
|
371
|
-
requireWiki();
|
|
406
|
+
requireWiki(this.storage);
|
|
372
407
|
|
|
373
408
|
const findings: LintFinding[] = [];
|
|
374
409
|
|
|
@@ -543,7 +578,7 @@ export class WikiManager {
|
|
|
543
578
|
* Get recent operations
|
|
544
579
|
*/
|
|
545
580
|
getRecentOperations(limit = 50): WikiOperation[] {
|
|
546
|
-
requireWiki();
|
|
581
|
+
requireWiki(this.storage);
|
|
547
582
|
return this.storage.getRecentOperations(limit);
|
|
548
583
|
}
|
|
549
584
|
}
|
|
@@ -557,7 +592,14 @@ export class WikiManager {
|
|
|
557
592
|
*/
|
|
558
593
|
export function createWikiManager(workspaceRoot: string, projectId: string): WikiManager {
|
|
559
594
|
const storage = createWikiStorage(workspaceRoot, projectId);
|
|
560
|
-
|
|
595
|
+
const manager = new WikiManager(storage);
|
|
596
|
+
|
|
597
|
+
// Initialize storage if feature is enabled (may be called lazily later)
|
|
598
|
+
if (process.env.HARNESS_KNOWLEDGE_WIKI === "1") {
|
|
599
|
+
storage.initialize();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return manager;
|
|
561
603
|
}
|
|
562
604
|
|
|
563
605
|
// Re-export storage class
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
readdirSync,
|
|
15
15
|
statSync,
|
|
16
16
|
} from "node:fs";
|
|
17
|
-
import { join, dirname, relative } from "node:path";
|
|
17
|
+
import { join, dirname, relative, resolve } from "node:path";
|
|
18
18
|
import { createHash, randomUUID } from "node:crypto";
|
|
19
19
|
import type {
|
|
20
20
|
WikiConfig,
|
|
@@ -66,7 +66,9 @@ export function parseFrontmatter(
|
|
|
66
66
|
const frontmatter = parseYaml(yamlStr);
|
|
67
67
|
|
|
68
68
|
return {
|
|
69
|
-
|
|
69
|
+
// SAFETY: parseYaml returns Record<string, unknown>, cast through unknown to satisfy TS
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
71
|
+
frontmatter: frontmatter as any,
|
|
70
72
|
body: body.trim(),
|
|
71
73
|
raw: yamlStr,
|
|
72
74
|
};
|
|
@@ -74,26 +76,75 @@ export function parseFrontmatter(
|
|
|
74
76
|
|
|
75
77
|
/**
|
|
76
78
|
* Simple YAML parser for frontmatter
|
|
77
|
-
* Handles: strings, booleans, numbers, arrays, nested objects
|
|
79
|
+
* Handles: strings, booleans, numbers, arrays, nested objects, YAML list items
|
|
78
80
|
*/
|
|
79
81
|
function parseYaml(yamlStr: string): Record<string, unknown> {
|
|
80
82
|
const result: Record<string, unknown> = {};
|
|
81
83
|
const lines = yamlStr.split("\n");
|
|
82
|
-
let currentKey = "";
|
|
83
|
-
let currentIndent = 0;
|
|
84
84
|
const stack: Array<{ obj: Record<string, unknown>; indent: number }> = [];
|
|
85
85
|
|
|
86
|
-
for (
|
|
86
|
+
for (let i = 0; i < lines.length; i++) {
|
|
87
|
+
const line = lines[i];
|
|
87
88
|
const trimmed = line.trim();
|
|
88
89
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
89
90
|
|
|
90
91
|
const indent = line.search(/\S/);
|
|
91
|
-
const colonIdx = trimmed.indexOf(":");
|
|
92
92
|
|
|
93
|
+
// Handle YAML list items (lines starting with '-')
|
|
94
|
+
if (trimmed.startsWith("-")) {
|
|
95
|
+
const itemContent = trimmed.slice(1).trim();
|
|
96
|
+
const colonIdx = itemContent.indexOf(":");
|
|
97
|
+
|
|
98
|
+
if (colonIdx !== -1) {
|
|
99
|
+
// This is a list item with key-value pairs on same line or next lines
|
|
100
|
+
const key = itemContent.slice(0, colonIdx).trim();
|
|
101
|
+
const value = itemContent.slice(colonIdx + 1).trim();
|
|
102
|
+
|
|
103
|
+
// Pop stack while indent decreases
|
|
104
|
+
while (stack.length > 0 && indent < stack[stack.length - 1].indent) {
|
|
105
|
+
stack.pop();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Find or create the array
|
|
109
|
+
const parent = stack.length > 0 ? stack[stack.length - 1].obj : result;
|
|
110
|
+
const arrayKey = parent ? Object.keys(parent).slice(-1)[0] : undefined;
|
|
111
|
+
let array: unknown[];
|
|
112
|
+
|
|
113
|
+
if (arrayKey && Array.isArray(parent[arrayKey])) {
|
|
114
|
+
array = parent[arrayKey] as unknown[];
|
|
115
|
+
} else {
|
|
116
|
+
// Start a new array
|
|
117
|
+
const newArray: unknown[] = [];
|
|
118
|
+
if (parent) {
|
|
119
|
+
parent[arrayKey || key] = newArray;
|
|
120
|
+
} else {
|
|
121
|
+
result[key] = newArray;
|
|
122
|
+
}
|
|
123
|
+
array = newArray;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Create new object for this list item
|
|
127
|
+
const item: Record<string, unknown> = {};
|
|
128
|
+
array.push(item);
|
|
129
|
+
|
|
130
|
+
// Parse the key-value on this line
|
|
131
|
+
const parsedValue = parseValue(value);
|
|
132
|
+
if (parsedValue !== undefined) {
|
|
133
|
+
item[key] = parsedValue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Push this item to stack for nested properties
|
|
137
|
+
stack.push({ obj: item, indent });
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Regular key-value line
|
|
143
|
+
const colonIdx = trimmed.indexOf(":");
|
|
93
144
|
if (colonIdx === -1) continue;
|
|
94
145
|
|
|
95
146
|
const key = trimmed.slice(0, colonIdx).trim();
|
|
96
|
-
|
|
147
|
+
const rawValue = trimmed.slice(colonIdx + 1).trim();
|
|
97
148
|
|
|
98
149
|
// Pop stack while indent decreases
|
|
99
150
|
while (stack.length > 0 && indent <= stack[stack.length - 1].indent) {
|
|
@@ -102,34 +153,41 @@ function parseYaml(yamlStr: string): Record<string, unknown> {
|
|
|
102
153
|
|
|
103
154
|
const parent = stack.length > 0 ? stack[stack.length - 1].obj : result;
|
|
104
155
|
|
|
105
|
-
//
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
} else if (/^-?\d+(\.\d+)?$/.test(value as string)) {
|
|
113
|
-
value = parseFloat(value as string);
|
|
114
|
-
} else if ((value as string).startsWith("[") && (value as string).endsWith("]")) {
|
|
115
|
-
// Array
|
|
116
|
-
const arrContent = (value as string).slice(1, -1);
|
|
117
|
-
value = arrContent
|
|
118
|
-
? arrContent.split(",").map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
119
|
-
: [];
|
|
120
|
-
} else {
|
|
121
|
-
// String - remove quotes
|
|
122
|
-
value = (value as string).replace(/^["']|["']$/g, "");
|
|
156
|
+
// Check if value is empty (nested content follows)
|
|
157
|
+
if (rawValue === "" || rawValue === "~" || rawValue === "null") {
|
|
158
|
+
// Nested content follows - create placeholder
|
|
159
|
+
const nested: Record<string, unknown> = {};
|
|
160
|
+
parent[key] = nested;
|
|
161
|
+
stack.push({ obj: nested, indent });
|
|
162
|
+
continue;
|
|
123
163
|
}
|
|
124
164
|
|
|
125
|
-
parent[key] =
|
|
126
|
-
currentKey = key;
|
|
127
|
-
currentIndent = indent;
|
|
165
|
+
parent[key] = parseValue(rawValue);
|
|
128
166
|
}
|
|
129
167
|
|
|
130
168
|
return result;
|
|
131
169
|
}
|
|
132
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Parse a YAML scalar value or array
|
|
173
|
+
*/
|
|
174
|
+
type YamlValue = string | number | boolean | null | string[];
|
|
175
|
+
function parseValue(value: string): YamlValue {
|
|
176
|
+
if (value === "" || value === "null" || value === "~") {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
if (value === "true") return true;
|
|
180
|
+
if (value === "false") return false;
|
|
181
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) {
|
|
182
|
+
return parseFloat(value);
|
|
183
|
+
}
|
|
184
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
185
|
+
const content = value.slice(1, -1);
|
|
186
|
+
return content ? content.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")) : [];
|
|
187
|
+
}
|
|
188
|
+
return value.replace(/^["']|["']$/g, "");
|
|
189
|
+
}
|
|
190
|
+
|
|
133
191
|
/**
|
|
134
192
|
* Serialize frontmatter to YAML string
|
|
135
193
|
*/
|
|
@@ -144,11 +202,15 @@ export function serializeFrontmatter(
|
|
|
144
202
|
if (Array.isArray(value)) {
|
|
145
203
|
if (value.length === 0) {
|
|
146
204
|
lines.push(`${key}: []`);
|
|
147
|
-
} else if (value.every((v) => typeof v === "object")) {
|
|
148
|
-
// Array of objects
|
|
205
|
+
} else if (value.every((v) => typeof v === "object" && v !== null)) {
|
|
206
|
+
// Array of objects - serialize as proper YAML
|
|
149
207
|
lines.push(`${key}:`);
|
|
150
208
|
for (const item of value) {
|
|
151
|
-
lines.push(` - ${
|
|
209
|
+
lines.push(` - sourceId: ${(item as Record<string, unknown>).sourceId}`);
|
|
210
|
+
lines.push(` path: ${(item as Record<string, unknown>).path}`);
|
|
211
|
+
lines.push(` contentHash: ${(item as Record<string, unknown>).contentHash}`);
|
|
212
|
+
lines.push(` capturedAt: ${(item as Record<string, unknown>).capturedAt}`);
|
|
213
|
+
lines.push(` redacted: ${(item as Record<string, unknown>).redacted}`);
|
|
152
214
|
}
|
|
153
215
|
} else {
|
|
154
216
|
lines.push(`${key}: [${value.join(", ")}]`);
|
|
@@ -195,16 +257,38 @@ export function combinePage(
|
|
|
195
257
|
export class WikiStorage {
|
|
196
258
|
private config: WikiConfig;
|
|
197
259
|
private index: Map<string, PageIndexEntry> = new Map();
|
|
260
|
+
private indexMtime = 0; // Track when index was last loaded
|
|
198
261
|
private operations: WikiOperation[] = [];
|
|
199
262
|
private stagedChanges: Map<string, StagedChange> = new Map();
|
|
200
|
-
private
|
|
263
|
+
private initialized = false;
|
|
201
264
|
|
|
202
265
|
constructor(config: WikiConfig) {
|
|
203
266
|
this.config = config;
|
|
204
|
-
|
|
267
|
+
// Don't create directories until explicitly initialized
|
|
268
|
+
// This prevents writes when the feature is disabled
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Initialize storage (call before any read/write operations)
|
|
273
|
+
* This is called automatically by WikiManager when feature is enabled
|
|
274
|
+
*/
|
|
275
|
+
/**
|
|
276
|
+
* Prepare storage for use (load indexes and operations)
|
|
277
|
+
* Called after directories are ensured by initialize()
|
|
278
|
+
*/
|
|
279
|
+
private prepareStorage(): void {
|
|
280
|
+
if (this.initialized) return;
|
|
205
281
|
this.loadIndex();
|
|
206
282
|
this.loadOperations();
|
|
207
283
|
this.loadStaged();
|
|
284
|
+
this.initialized = true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Check if storage is initialized
|
|
289
|
+
*/
|
|
290
|
+
isInitialized(): boolean {
|
|
291
|
+
return this.initialized;
|
|
208
292
|
}
|
|
209
293
|
|
|
210
294
|
/**
|
|
@@ -302,20 +386,26 @@ verification:
|
|
|
302
386
|
}
|
|
303
387
|
|
|
304
388
|
/**
|
|
305
|
-
*
|
|
389
|
+
* Load page index from disk (if changed since last load)
|
|
306
390
|
*/
|
|
307
|
-
private
|
|
308
|
-
if (!
|
|
309
|
-
|
|
391
|
+
private loadIndex(force = false): void {
|
|
392
|
+
if (!force) {
|
|
393
|
+
try {
|
|
394
|
+
const stat = statSync(this.config.indexFile);
|
|
395
|
+
// Only reload if file was modified since last load
|
|
396
|
+
if (stat.mtimeMs <= this.indexMtime) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
} catch {
|
|
400
|
+
// File doesn't exist or can't be stat'd
|
|
401
|
+
}
|
|
310
402
|
}
|
|
311
|
-
}
|
|
312
403
|
|
|
313
|
-
/**
|
|
314
|
-
* Load page index from disk
|
|
315
|
-
*/
|
|
316
|
-
private loadIndex(): void {
|
|
317
404
|
this.index.clear();
|
|
318
|
-
if (!existsSync(this.config.indexFile))
|
|
405
|
+
if (!existsSync(this.config.indexFile)) {
|
|
406
|
+
this.indexMtime = 0;
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
319
409
|
|
|
320
410
|
try {
|
|
321
411
|
const content = readFileSync(this.config.indexFile, "utf8");
|
|
@@ -323,21 +413,51 @@ verification:
|
|
|
323
413
|
for (const entry of entries) {
|
|
324
414
|
this.index.set(entry.id, entry);
|
|
325
415
|
}
|
|
416
|
+
const stat = statSync(this.config.indexFile);
|
|
417
|
+
this.indexMtime = stat.mtimeMs;
|
|
326
418
|
} catch {
|
|
327
419
|
// Invalid index, rebuild
|
|
420
|
+
this.indexMtime = 0;
|
|
328
421
|
}
|
|
329
422
|
}
|
|
330
423
|
|
|
331
424
|
/**
|
|
332
|
-
* Save page index to disk (atomic)
|
|
425
|
+
* Save page index to disk (atomic, merges with existing entries)
|
|
333
426
|
*/
|
|
334
427
|
private saveIndex(): void {
|
|
335
|
-
|
|
428
|
+
// Reload from disk to merge (handles concurrent writes)
|
|
429
|
+
const diskEntries = this.loadIndexFromDisk();
|
|
430
|
+
const merged = new Map<string, PageIndexEntry>(diskEntries);
|
|
431
|
+
|
|
432
|
+
// Apply in-memory changes
|
|
433
|
+
for (const [id, entry] of this.index) {
|
|
434
|
+
merged.set(id, entry);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const entries = Array.from(merged.values());
|
|
336
438
|
const content = JSON.stringify(entries, null, 2);
|
|
337
439
|
const tmpFile = this.config.indexFile + ".tmp";
|
|
338
440
|
writeFileSync(tmpFile, content, "utf8");
|
|
339
441
|
renameSync(tmpFile, this.config.indexFile);
|
|
340
|
-
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Load index from disk only (no cache)
|
|
446
|
+
*/
|
|
447
|
+
private loadIndexFromDisk(): Map<string, PageIndexEntry> {
|
|
448
|
+
const result = new Map<string, PageIndexEntry>();
|
|
449
|
+
if (!existsSync(this.config.indexFile)) return result;
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
const content = readFileSync(this.config.indexFile, "utf8");
|
|
453
|
+
const entries: PageIndexEntry[] = JSON.parse(content);
|
|
454
|
+
for (const entry of entries) {
|
|
455
|
+
result.set(entry.id, entry);
|
|
456
|
+
}
|
|
457
|
+
} catch {
|
|
458
|
+
// Invalid index, return empty
|
|
459
|
+
}
|
|
460
|
+
return result;
|
|
341
461
|
}
|
|
342
462
|
|
|
343
463
|
/**
|
|
@@ -436,7 +556,9 @@ verification:
|
|
|
436
556
|
* Get source snapshot by ID
|
|
437
557
|
*/
|
|
438
558
|
getSnapshot(snapshotId: string): SourceSnapshot | null {
|
|
559
|
+
this.validateSnapshotId(snapshotId);
|
|
439
560
|
const snapFile = join(this.config.rawDir, `${snapshotId}.json`);
|
|
561
|
+
this.ensureWithinDirectory(snapFile, this.config.rawDir);
|
|
440
562
|
if (!existsSync(snapFile)) return null;
|
|
441
563
|
|
|
442
564
|
try {
|
|
@@ -455,8 +577,10 @@ verification:
|
|
|
455
577
|
* Save a wiki page
|
|
456
578
|
*/
|
|
457
579
|
savePage(page: WikiPage): void {
|
|
580
|
+
this.validatePageId(page.frontmatter.id);
|
|
458
581
|
const content = combinePage(page.frontmatter, page.body);
|
|
459
582
|
const pageFile = join(this.config.knowledgeDir, `${page.frontmatter.id}.md`);
|
|
583
|
+
this.ensureWithinDirectory(pageFile, this.config.knowledgeDir);
|
|
460
584
|
const tmpFile = pageFile + ".tmp";
|
|
461
585
|
writeFileSync(tmpFile, content, "utf8");
|
|
462
586
|
renameSync(tmpFile, pageFile);
|
|
@@ -474,14 +598,15 @@ verification:
|
|
|
474
598
|
};
|
|
475
599
|
this.index.set(entry.id, entry);
|
|
476
600
|
this.saveIndex();
|
|
477
|
-
this.dirty = true;
|
|
478
601
|
}
|
|
479
602
|
|
|
480
603
|
/**
|
|
481
604
|
* Load a wiki page
|
|
482
605
|
*/
|
|
483
606
|
loadPage(pageId: string): WikiPage | null {
|
|
607
|
+
this.validatePageId(pageId);
|
|
484
608
|
const pageFile = join(this.config.knowledgeDir, `${pageId}.md`);
|
|
609
|
+
this.ensureWithinDirectory(pageFile, this.config.knowledgeDir);
|
|
485
610
|
if (!existsSync(pageFile)) return null;
|
|
486
611
|
|
|
487
612
|
const content = readFileSync(pageFile, "utf8");
|
|
@@ -492,10 +617,42 @@ verification:
|
|
|
492
617
|
return { frontmatter, body, rawFrontmatter: raw };
|
|
493
618
|
}
|
|
494
619
|
|
|
620
|
+
/**
|
|
621
|
+
* Validate page ID to prevent path traversal attacks
|
|
622
|
+
*/
|
|
623
|
+
private validatePageId(pageId: string): void {
|
|
624
|
+
// Reject paths with directory separators or parent references
|
|
625
|
+
if (pageId.includes("/") || pageId.includes("\\") || pageId.includes("..")) {
|
|
626
|
+
throw new Error(`Invalid page ID: ${pageId}`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Validate snapshot ID to prevent path traversal attacks
|
|
632
|
+
*/
|
|
633
|
+
private validateSnapshotId(snapshotId: string): void {
|
|
634
|
+
// Reject paths with directory separators or parent references
|
|
635
|
+
if (snapshotId.includes("/") || snapshotId.includes("\\") || snapshotId.includes("..")) {
|
|
636
|
+
throw new Error(`Invalid snapshot ID: ${snapshotId}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Ensure a path is within the expected directory (defense in depth)
|
|
642
|
+
*/
|
|
643
|
+
private ensureWithinDirectory(filePath: string, expectedDir: string): void {
|
|
644
|
+
const resolved = resolve(filePath);
|
|
645
|
+
const expected = resolve(expectedDir);
|
|
646
|
+
if (!resolved.startsWith(expected + "/") && resolved !== expected) {
|
|
647
|
+
throw new Error(`Path ${filePath} is outside expected directory ${expectedDir}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
495
651
|
/**
|
|
496
652
|
* Delete a wiki page
|
|
497
653
|
*/
|
|
498
654
|
deletePage(pageId: string): void {
|
|
655
|
+
this.validatePageId(pageId);
|
|
499
656
|
const pageFile = join(this.config.knowledgeDir, `${pageId}.md`);
|
|
500
657
|
if (existsSync(pageFile)) {
|
|
501
658
|
unlinkSync(pageFile);
|
|
@@ -510,6 +667,9 @@ verification:
|
|
|
510
667
|
* Get all pages
|
|
511
668
|
*/
|
|
512
669
|
getAllPages(): WikiPage[] {
|
|
670
|
+
// Reload index to get latest changes from other processes
|
|
671
|
+
this.loadIndex();
|
|
672
|
+
|
|
513
673
|
const pages: WikiPage[] = [];
|
|
514
674
|
if (!existsSync(this.config.knowledgeDir)) return pages;
|
|
515
675
|
|
|
@@ -555,6 +715,8 @@ verification:
|
|
|
555
715
|
* Get all staged changes
|
|
556
716
|
*/
|
|
557
717
|
getAllStagedChanges(): StagedChange[] {
|
|
718
|
+
// Reload staged changes to get latest from other processes
|
|
719
|
+
this.loadStaged();
|
|
558
720
|
return Array.from(this.stagedChanges.values());
|
|
559
721
|
}
|
|
560
722
|
|
|
@@ -603,6 +765,19 @@ verification:
|
|
|
603
765
|
renameSync(tmpFile, this.config.logFile);
|
|
604
766
|
}
|
|
605
767
|
|
|
768
|
+
/**
|
|
769
|
+
* Update operation details (before completion)
|
|
770
|
+
*/
|
|
771
|
+
updateOperationDetails(opId: string, details: Record<string, unknown>): void {
|
|
772
|
+
const opIndex = this.operations.findIndex((o) => o.id === opId);
|
|
773
|
+
if (opIndex === -1) return;
|
|
774
|
+
|
|
775
|
+
this.operations[opIndex] = {
|
|
776
|
+
...this.operations[opIndex],
|
|
777
|
+
details: { ...this.operations[opIndex].details, ...details },
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
606
781
|
/**
|
|
607
782
|
* Get recent operations
|
|
608
783
|
*/
|
|
@@ -621,7 +796,7 @@ verification:
|
|
|
621
796
|
const queryLower = query.toLowerCase();
|
|
622
797
|
const results: WikiSearchResult[] = [];
|
|
623
798
|
|
|
624
|
-
for (const [
|
|
799
|
+
for (const [_id, entry] of this.index) {
|
|
625
800
|
// Apply filters
|
|
626
801
|
if (filters?.kinds?.length && !filters.kinds.includes(entry.kind)) continue;
|
|
627
802
|
if (filters?.statuses?.length && !filters.statuses.includes(entry.status)) continue;
|