backlog-mcp 0.31.0 → 0.33.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.
- package/dist/events/event-bus.d.mts +27 -0
- package/dist/events/event-bus.mjs +1 -0
- package/dist/events/index.d.mts +9 -0
- package/dist/events/index.mjs +9 -0
- package/dist/events/index.mjs.map +1 -0
- package/dist/events/local-event-bus.d.mts +16 -0
- package/dist/events/local-event-bus.mjs +39 -0
- package/dist/events/local-event-bus.mjs.map +1 -0
- package/dist/operations/middleware.d.mts +1 -1
- package/dist/operations/middleware.mjs +23 -2
- package/dist/operations/middleware.mjs.map +1 -1
- package/dist/operations/resource-id.mjs +3 -2
- package/dist/operations/resource-id.mjs.map +1 -1
- package/dist/resources/manager.mjs +7 -1
- package/dist/resources/manager.mjs.map +1 -1
- package/dist/search/orama-search-service.mjs +4 -2
- package/dist/search/orama-search-service.mjs.map +1 -1
- package/dist/search/types.d.mts +1 -0
- package/dist/server/viewer-routes.mjs +38 -2
- package/dist/server/viewer-routes.mjs.map +1 -1
- package/dist/storage/backlog-service.d.mts +3 -1
- package/dist/storage/backlog-service.mjs +2 -1
- package/dist/storage/backlog-service.mjs.map +1 -1
- package/dist/storage/schema.d.mts +18 -5
- package/dist/storage/schema.mjs +34 -9
- package/dist/storage/schema.mjs.map +1 -1
- package/dist/storage/task-storage.d.mts +3 -1
- package/dist/storage/task-storage.mjs +13 -5
- package/dist/storage/task-storage.mjs.map +1 -1
- package/dist/substrates/index.d.mts +118 -0
- package/dist/substrates/index.mjs +118 -0
- package/dist/substrates/index.mjs.map +1 -0
- package/dist/tools/backlog-create.mjs +5 -2
- package/dist/tools/backlog-create.mjs.map +1 -1
- package/dist/tools/backlog-list.mjs +7 -5
- package/dist/tools/backlog-list.mjs.map +1 -1
- package/dist/tools/backlog-update.mjs +21 -2
- package/dist/tools/backlog-update.mjs.map +1 -1
- package/dist/viewer/artifact-BCQPBRQV.svg +4 -0
- package/dist/viewer/folder-NDM5UKIX.svg +3 -0
- package/dist/viewer/main.css +89 -0
- package/dist/viewer/main.js +319 -169
- package/dist/viewer/milestone-B6JUSBZD.svg +4 -0
- package/package.json +1 -1
- package/viewer/components/activity-panel.ts +18 -41
- package/viewer/components/activity-utils.ts +7 -2
- package/viewer/components/backlog-app.ts +16 -4
- package/viewer/components/breadcrumb.ts +27 -18
- package/viewer/components/spotlight-search.ts +2 -7
- package/viewer/components/task-badge.ts +4 -4
- package/viewer/components/task-detail.ts +26 -12
- package/viewer/components/task-filter-bar.ts +40 -5
- package/viewer/components/task-item.ts +30 -12
- package/viewer/components/task-list.ts +96 -63
- package/viewer/icons/artifact.svg +4 -0
- package/viewer/icons/folder.svg +3 -0
- package/viewer/icons/index.ts +6 -0
- package/viewer/icons/milestone.svg +4 -0
- package/viewer/main.ts +6 -9
- package/viewer/services/event-source-client.ts +62 -0
- package/viewer/styles.css +61 -0
- package/viewer/type-registry.ts +37 -0
- package/viewer/utils/api.ts +6 -1
- package/viewer/utils/sidebar-scope.ts +24 -0
- package/viewer/utils/url-state.ts +14 -4
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region src/events/event-bus.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* EventBus interface and event types for real-time viewer updates.
|
|
4
|
+
*
|
|
5
|
+
* The interface is designed to be pluggable: start with in-process
|
|
6
|
+
* EventEmitter (LocalEventBus), swap to Redis Pub/Sub or NATS
|
|
7
|
+
* for cloud deployment without changing consumers.
|
|
8
|
+
*/
|
|
9
|
+
type BacklogEventType = 'task_changed' | 'task_created' | 'task_deleted' | 'resource_changed';
|
|
10
|
+
interface BacklogEvent {
|
|
11
|
+
seq: number;
|
|
12
|
+
type: BacklogEventType;
|
|
13
|
+
id: string;
|
|
14
|
+
tool: string;
|
|
15
|
+
actor: string;
|
|
16
|
+
ts: string;
|
|
17
|
+
}
|
|
18
|
+
type BacklogEventCallback = (event: BacklogEvent) => void;
|
|
19
|
+
interface EventBus {
|
|
20
|
+
emit(event: Omit<BacklogEvent, 'seq'>): void;
|
|
21
|
+
subscribe(callback: BacklogEventCallback): void;
|
|
22
|
+
unsubscribe(callback: BacklogEventCallback): void;
|
|
23
|
+
replaySince(seq: number): BacklogEvent[];
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
export { BacklogEvent, BacklogEventCallback, BacklogEventType, EventBus };
|
|
27
|
+
//# sourceMappingURL=event-bus.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { BacklogEvent, BacklogEventCallback, BacklogEventType, EventBus } from "./event-bus.mjs";
|
|
2
|
+
import { LocalEventBus } from "./local-event-bus.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/events/index.d.ts
|
|
5
|
+
/** Singleton event bus instance. Swap implementation for cloud deployment. */
|
|
6
|
+
declare const eventBus: LocalEventBus;
|
|
7
|
+
//#endregion
|
|
8
|
+
export { type BacklogEvent, type BacklogEventCallback, type BacklogEventType, type EventBus, LocalEventBus, eventBus };
|
|
9
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { LocalEventBus } from "./local-event-bus.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/events/index.ts
|
|
4
|
+
/** Singleton event bus instance. Swap implementation for cloud deployment. */
|
|
5
|
+
const eventBus = new LocalEventBus();
|
|
6
|
+
|
|
7
|
+
//#endregion
|
|
8
|
+
export { LocalEventBus, eventBus };
|
|
9
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/events/index.ts"],"sourcesContent":["export type { BacklogEvent, BacklogEventType, BacklogEventCallback, EventBus } from './event-bus.js';\nexport { LocalEventBus } from './local-event-bus.js';\n\nimport { LocalEventBus } from './local-event-bus.js';\n\n/** Singleton event bus instance. Swap implementation for cloud deployment. */\nexport const eventBus = new LocalEventBus();\n"],"mappings":";;;;AAMA,MAAa,WAAW,IAAI,eAAe"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { BacklogEvent, BacklogEventCallback, EventBus } from "./event-bus.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/events/local-event-bus.d.ts
|
|
4
|
+
declare class LocalEventBus implements EventBus {
|
|
5
|
+
private emitter;
|
|
6
|
+
private seq;
|
|
7
|
+
private buffer;
|
|
8
|
+
constructor();
|
|
9
|
+
emit(event: Omit<BacklogEvent, 'seq'>): void;
|
|
10
|
+
subscribe(callback: BacklogEventCallback): void;
|
|
11
|
+
unsubscribe(callback: BacklogEventCallback): void;
|
|
12
|
+
replaySince(seq: number): BacklogEvent[];
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
export { LocalEventBus };
|
|
16
|
+
//# sourceMappingURL=local-event-bus.d.mts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
|
|
3
|
+
//#region src/events/local-event-bus.ts
|
|
4
|
+
/**
|
|
5
|
+
* In-process EventBus implementation using Node.js EventEmitter.
|
|
6
|
+
* Maintains a ring buffer for replay on SSE reconnect.
|
|
7
|
+
*/
|
|
8
|
+
const RING_BUFFER_SIZE = 1e3;
|
|
9
|
+
const EVENT_NAME = "backlog";
|
|
10
|
+
var LocalEventBus = class {
|
|
11
|
+
emitter = new EventEmitter();
|
|
12
|
+
seq = 0;
|
|
13
|
+
buffer = [];
|
|
14
|
+
constructor() {
|
|
15
|
+
this.emitter.setMaxListeners(100);
|
|
16
|
+
}
|
|
17
|
+
emit(event) {
|
|
18
|
+
const full = {
|
|
19
|
+
...event,
|
|
20
|
+
seq: ++this.seq
|
|
21
|
+
};
|
|
22
|
+
if (this.buffer.length >= RING_BUFFER_SIZE) this.buffer.shift();
|
|
23
|
+
this.buffer.push(full);
|
|
24
|
+
this.emitter.emit(EVENT_NAME, full);
|
|
25
|
+
}
|
|
26
|
+
subscribe(callback) {
|
|
27
|
+
this.emitter.on(EVENT_NAME, callback);
|
|
28
|
+
}
|
|
29
|
+
unsubscribe(callback) {
|
|
30
|
+
this.emitter.off(EVENT_NAME, callback);
|
|
31
|
+
}
|
|
32
|
+
replaySince(seq) {
|
|
33
|
+
return this.buffer.filter((e) => e.seq > seq);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
//#endregion
|
|
38
|
+
export { LocalEventBus };
|
|
39
|
+
//# sourceMappingURL=local-event-bus.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-event-bus.mjs","names":[],"sources":["../../src/events/local-event-bus.ts"],"sourcesContent":["/**\n * In-process EventBus implementation using Node.js EventEmitter.\n * Maintains a ring buffer for replay on SSE reconnect.\n */\n\nimport { EventEmitter } from 'node:events';\nimport type { BacklogEvent, BacklogEventCallback, EventBus } from './event-bus.js';\n\nconst RING_BUFFER_SIZE = 1000;\nconst EVENT_NAME = 'backlog';\n\nexport class LocalEventBus implements EventBus {\n private emitter = new EventEmitter();\n private seq = 0;\n private buffer: BacklogEvent[] = [];\n\n constructor() {\n this.emitter.setMaxListeners(100);\n }\n\n emit(event: Omit<BacklogEvent, 'seq'>): void {\n const full: BacklogEvent = { ...event, seq: ++this.seq };\n\n // Ring buffer: drop oldest when full\n if (this.buffer.length >= RING_BUFFER_SIZE) {\n this.buffer.shift();\n }\n this.buffer.push(full);\n\n this.emitter.emit(EVENT_NAME, full);\n }\n\n subscribe(callback: BacklogEventCallback): void {\n this.emitter.on(EVENT_NAME, callback);\n }\n\n unsubscribe(callback: BacklogEventCallback): void {\n this.emitter.off(EVENT_NAME, callback);\n }\n\n replaySince(seq: number): BacklogEvent[] {\n return this.buffer.filter(e => e.seq > seq);\n }\n}\n"],"mappings":";;;;;;;AAQA,MAAM,mBAAmB;AACzB,MAAM,aAAa;AAEnB,IAAa,gBAAb,MAA+C;CAC7C,AAAQ,UAAU,IAAI,cAAc;CACpC,AAAQ,MAAM;CACd,AAAQ,SAAyB,EAAE;CAEnC,cAAc;AACZ,OAAK,QAAQ,gBAAgB,IAAI;;CAGnC,KAAK,OAAwC;EAC3C,MAAM,OAAqB;GAAE,GAAG;GAAO,KAAK,EAAE,KAAK;GAAK;AAGxD,MAAI,KAAK,OAAO,UAAU,iBACxB,MAAK,OAAO,OAAO;AAErB,OAAK,OAAO,KAAK,KAAK;AAEtB,OAAK,QAAQ,KAAK,YAAY,KAAK;;CAGrC,UAAU,UAAsC;AAC9C,OAAK,QAAQ,GAAG,YAAY,SAAS;;CAGvC,YAAY,UAAsC;AAChD,OAAK,QAAQ,IAAI,YAAY,SAAS;;CAGxC,YAAY,KAA6B;AACvC,SAAO,KAAK,OAAO,QAAO,MAAK,EAAE,MAAM,IAAI"}
|
|
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
|
|
3
3
|
//#region src/operations/middleware.d.ts
|
|
4
4
|
/**
|
|
5
|
-
* Wrap an MCP server to log tool operations.
|
|
5
|
+
* Wrap an MCP server to log tool operations and emit real-time events.
|
|
6
6
|
* Returns a proxy that intercepts registerTool calls.
|
|
7
7
|
*/
|
|
8
8
|
declare function withOperationLogging(server: McpServer): McpServer;
|
|
@@ -1,8 +1,21 @@
|
|
|
1
|
+
import { extractResourceId } from "./resource-id.mjs";
|
|
2
|
+
import { WRITE_TOOLS } from "./types.mjs";
|
|
1
3
|
import { operationLogger } from "./logger.mjs";
|
|
4
|
+
import { eventBus } from "../events/index.mjs";
|
|
2
5
|
|
|
3
6
|
//#region src/operations/middleware.ts
|
|
7
|
+
/** Declarative mapping from write tool to event type. */
|
|
8
|
+
const TOOL_EVENT_MAP = {
|
|
9
|
+
backlog_create: "task_created",
|
|
10
|
+
backlog_update: "task_changed",
|
|
11
|
+
backlog_delete: "task_deleted",
|
|
12
|
+
write_resource: "resource_changed"
|
|
13
|
+
};
|
|
14
|
+
function isWriteTool(name) {
|
|
15
|
+
return WRITE_TOOLS.includes(name);
|
|
16
|
+
}
|
|
4
17
|
/**
|
|
5
|
-
* Wrap an MCP server to log tool operations.
|
|
18
|
+
* Wrap an MCP server to log tool operations and emit real-time events.
|
|
6
19
|
* Returns a proxy that intercepts registerTool calls.
|
|
7
20
|
*/
|
|
8
21
|
function withOperationLogging(server) {
|
|
@@ -10,7 +23,15 @@ function withOperationLogging(server) {
|
|
|
10
23
|
server.registerTool = function(name, config, callback) {
|
|
11
24
|
const wrappedCallback = async (...args) => {
|
|
12
25
|
const result = await callback(...args);
|
|
13
|
-
|
|
26
|
+
const params = args[0] || {};
|
|
27
|
+
operationLogger.log(name, params, result);
|
|
28
|
+
if (isWriteTool(name)) eventBus.emit({
|
|
29
|
+
type: TOOL_EVENT_MAP[name],
|
|
30
|
+
id: extractResourceId(name, params, result) || "",
|
|
31
|
+
tool: name,
|
|
32
|
+
actor: process.env.BACKLOG_ACTOR_NAME || process.env.USER || "unknown",
|
|
33
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
34
|
+
});
|
|
14
35
|
return result;
|
|
15
36
|
};
|
|
16
37
|
return originalRegisterTool(name, config, wrappedCallback);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.mjs","names":[],"sources":["../../src/operations/middleware.ts"],"sourcesContent":["import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { operationLogger } from './logger.js';\n\n/**\n * Wrap an MCP server to log tool operations.\n * Returns a proxy that intercepts registerTool calls.\n */\nexport function withOperationLogging(server: McpServer): McpServer {\n const originalRegisterTool = server.registerTool.bind(server);\n\n // Override registerTool to wrap callbacks with logging\n (server as any).registerTool = function(\n name: string,\n config: any,\n callback: (...args: any[]) => any\n ) {\n const wrappedCallback = async (...args: any[]) => {\n const result = await callback(...args);\n
|
|
1
|
+
{"version":3,"file":"middleware.mjs","names":[],"sources":["../../src/operations/middleware.ts"],"sourcesContent":["import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { operationLogger } from './logger.js';\nimport { eventBus } from '../events/index.js';\nimport type { BacklogEventType } from '../events/index.js';\nimport { extractResourceId } from './resource-id.js';\nimport { WRITE_TOOLS, type ToolName } from './types.js';\n\n/** Declarative mapping from write tool to event type. */\nconst TOOL_EVENT_MAP: Record<ToolName, BacklogEventType> = {\n backlog_create: 'task_created',\n backlog_update: 'task_changed',\n backlog_delete: 'task_deleted',\n write_resource: 'resource_changed',\n};\n\nfunction isWriteTool(name: string): name is ToolName {\n return WRITE_TOOLS.includes(name as ToolName);\n}\n\n/**\n * Wrap an MCP server to log tool operations and emit real-time events.\n * Returns a proxy that intercepts registerTool calls.\n */\nexport function withOperationLogging(server: McpServer): McpServer {\n const originalRegisterTool = server.registerTool.bind(server);\n\n // Override registerTool to wrap callbacks with logging\n (server as any).registerTool = function(\n name: string,\n config: any,\n callback: (...args: any[]) => any\n ) {\n const wrappedCallback = async (...args: any[]) => {\n const result = await callback(...args);\n const params = args[0] || {};\n\n // Log operation to disk\n operationLogger.log(name, params, result);\n\n // Emit real-time event for SSE consumers\n if (isWriteTool(name)) {\n eventBus.emit({\n type: TOOL_EVENT_MAP[name],\n id: extractResourceId(name, params, result) || '',\n tool: name,\n actor: process.env.BACKLOG_ACTOR_NAME || process.env.USER || 'unknown',\n ts: new Date().toISOString(),\n });\n }\n\n return result;\n };\n\n return originalRegisterTool(name, config, wrappedCallback as any);\n };\n\n return server;\n}\n"],"mappings":";;;;;;;AAQA,MAAM,iBAAqD;CACzD,gBAAgB;CAChB,gBAAgB;CAChB,gBAAgB;CAChB,gBAAgB;CACjB;AAED,SAAS,YAAY,MAAgC;AACnD,QAAO,YAAY,SAAS,KAAiB;;;;;;AAO/C,SAAgB,qBAAqB,QAA8B;CACjE,MAAM,uBAAuB,OAAO,aAAa,KAAK,OAAO;AAG7D,CAAC,OAAe,eAAe,SAC7B,MACA,QACA,UACA;EACA,MAAM,kBAAkB,OAAO,GAAG,SAAgB;GAChD,MAAM,SAAS,MAAM,SAAS,GAAG,KAAK;GACtC,MAAM,SAAS,KAAK,MAAM,EAAE;AAG5B,mBAAgB,IAAI,MAAM,QAAQ,OAAO;AAGzC,OAAI,YAAY,KAAK,CACnB,UAAS,KAAK;IACZ,MAAM,eAAe;IACrB,IAAI,kBAAkB,MAAM,QAAQ,OAAO,IAAI;IAC/C,MAAM;IACN,OAAO,QAAQ,IAAI,sBAAsB,QAAQ,IAAI,QAAQ;IAC7D,qBAAI,IAAI,MAAM,EAAC,aAAa;IAC7B,CAAC;AAGJ,UAAO;;AAGT,SAAO,qBAAqB,MAAM,QAAQ,gBAAuB;;AAGnE,QAAO"}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
//#region src/operations/resource-id.ts
|
|
2
|
+
const ID_RE = /(TASK|EPIC|FLDR|ARTF|MLST)-\d+/;
|
|
2
3
|
const extractors = {
|
|
3
4
|
backlog_create: (_, result) => {
|
|
4
5
|
const text = result?.content?.[0]?.text;
|
|
5
|
-
|
|
6
|
+
return text ? ID_RE.exec(text)?.[0] : void 0;
|
|
6
7
|
},
|
|
7
8
|
backlog_update: (params) => params.id,
|
|
8
9
|
backlog_delete: (params) => params.id,
|
|
9
10
|
write_resource: (params) => {
|
|
10
11
|
const uri = params.uri;
|
|
11
|
-
|
|
12
|
+
return uri ? ID_RE.exec(uri)?.[0] : void 0;
|
|
12
13
|
}
|
|
13
14
|
};
|
|
14
15
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resource-id.mjs","names":[],"sources":["../../src/operations/resource-id.ts"],"sourcesContent":["/**\n * Resource ID extraction from tool params/results.\n * Each tool has its own extraction strategy.\n */\n\ntype Extractor = (params: Record<string, unknown>, result: unknown) => string | undefined;\n\nconst extractors: Record<string, Extractor> = {\n backlog_create: (_, result) => {\n const text = (result as any)?.content?.[0]?.text as string | undefined;\n
|
|
1
|
+
{"version":3,"file":"resource-id.mjs","names":[],"sources":["../../src/operations/resource-id.ts"],"sourcesContent":["/**\n * Resource ID extraction from tool params/results.\n * Each tool has its own extraction strategy.\n */\n\ntype Extractor = (params: Record<string, unknown>, result: unknown) => string | undefined;\n\nconst ID_RE = /(TASK|EPIC|FLDR|ARTF|MLST)-\\d+/;\n\nconst extractors: Record<string, Extractor> = {\n backlog_create: (_, result) => {\n const text = (result as any)?.content?.[0]?.text as string | undefined;\n return text ? ID_RE.exec(text)?.[0] : undefined;\n },\n\n backlog_update: (params) => params.id as string | undefined,\n\n backlog_delete: (params) => params.id as string | undefined,\n\n write_resource: (params) => {\n const uri = params.uri as string | undefined;\n return uri ? ID_RE.exec(uri)?.[0] : undefined;\n },\n};\n\n/**\n * Extract resource ID from tool params or result for filtering.\n */\nexport function extractResourceId(\n tool: string,\n params: Record<string, unknown>,\n result: unknown\n): string | undefined {\n const extractor = extractors[tool];\n return extractor ? extractor(params, result) : undefined;\n}\n"],"mappings":";AAOA,MAAM,QAAQ;AAEd,MAAM,aAAwC;CAC5C,iBAAiB,GAAG,WAAW;EAC7B,MAAM,OAAQ,QAAgB,UAAU,IAAI;AAC5C,SAAO,OAAO,MAAM,KAAK,KAAK,GAAG,KAAK;;CAGxC,iBAAiB,WAAW,OAAO;CAEnC,iBAAiB,WAAW,OAAO;CAEnC,iBAAiB,WAAW;EAC1B,MAAM,MAAM,OAAO;AACnB,SAAO,MAAM,MAAM,KAAK,IAAI,GAAG,KAAK;;CAEvC;;;;AAKD,SAAgB,kBACd,MACA,QACA,QACoB;CACpB,MAAM,YAAY,WAAW;AAC7B,QAAO,YAAY,UAAU,QAAQ,OAAO,GAAG"}
|
|
@@ -142,7 +142,13 @@ var ResourceManager = class {
|
|
|
142
142
|
message: "File not found",
|
|
143
143
|
error: `Resource not found: ${uri} (${operation.type} requires existing file)`
|
|
144
144
|
};
|
|
145
|
-
|
|
145
|
+
const fileContent = readFileSync(filePath, "utf-8");
|
|
146
|
+
if (isTask && operation.type === "create" && fileContent) return {
|
|
147
|
+
success: false,
|
|
148
|
+
message: "Cannot overwrite existing task file",
|
|
149
|
+
error: `${uri} already exists. Use str_replace to edit content, or backlog_update to update metadata.`
|
|
150
|
+
};
|
|
151
|
+
let newContent = applyOperation(fileContent, operation);
|
|
146
152
|
if (isTask) newContent = this.updateTaskTimestamp(newContent);
|
|
147
153
|
writeFileSync(filePath, newContent, "utf-8");
|
|
148
154
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manager.mjs","names":[],"sources":["../../src/resources/manager.ts"],"sourcesContent":["import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';\nimport { join, dirname, relative, basename } from 'node:path';\nimport matter from 'gray-matter';\nimport { z } from 'zod';\nimport { paths } from '@/utils/paths.js';\nimport { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport type { Operation, WriteResourceResult } from './types.js';\nimport { applyOperation } from './operations.js';\nimport type { Resource } from '@/search/types.js';\n\nexport interface ResourceContent {\n content: string;\n frontmatter?: Record<string, any>;\n mimeType: string;\n}\n\n/**\n * Extract title from markdown content.\n * Returns first # heading or filename without extension.\n */\nfunction extractTitle(content: string, filename: string): string {\n const match = content.match(/^#\\s+(.+)$/m);\n return match?.[1]?.trim() || filename.replace(/\\.md$/, '');\n}\n\n/**\n * ResourceManager - Single point of responsibility for MCP resource operations.\n * \n * Pure catch-all design: mcp://backlog/{+path} → {dataDir}/{path}\n * No special cases, no magic behavior.\n */\nexport class ResourceManager {\n constructor(private readonly dataDir: string) {}\n\n /**\n * List all resources in the resources/ directory.\n * Returns Resource objects ready for search indexing.\n */\n list(): Resource[] {\n const resourcesDir = join(this.dataDir, 'resources');\n if (!existsSync(resourcesDir)) return [];\n\n const resources: Resource[] = [];\n this.scanDirectory(resourcesDir, resources);\n return resources;\n }\n\n private scanDirectory(dir: string, resources: Resource[]): void {\n const entries = readdirSync(dir, { withFileTypes: true });\n \n for (const entry of entries) {\n const fullPath = join(dir, entry.name);\n \n if (entry.isDirectory()) {\n this.scanDirectory(fullPath, resources);\n } else if (entry.isFile() && entry.name.endsWith('.md')) {\n try {\n const content = readFileSync(fullPath, 'utf-8');\n const relativePath = relative(this.dataDir, fullPath);\n const uri = `mcp://backlog/${relativePath}`;\n \n resources.push({\n id: uri,\n path: relativePath,\n title: extractTitle(content, entry.name),\n content,\n });\n } catch {\n // Skip files that can't be read\n }\n }\n }\n }\n\n /**\n * Resolve MCP URI to absolute file path.\n * Pure catch-all: mcp://backlog/path/file.md → {dataDir}/path/file.md\n * \n * @param uri MCP URI (must start with mcp://backlog/)\n * @returns Absolute file path\n * @throws Error if URI is invalid or contains path traversal\n */\n resolve(uri: string): string {\n if (!uri.startsWith('mcp://')) {\n throw new Error(`Not an MCP URI: ${uri}`);\n }\n\n // Check for path traversal BEFORE URL parsing (URL normalizes ..)\n if (uri.includes('..')) {\n throw new Error(`Path traversal not allowed: ${uri}`);\n }\n\n const url = new URL(uri);\n \n if (url.hostname !== 'backlog') {\n throw new Error(`Invalid hostname: ${url.hostname}. Expected 'backlog'`);\n }\n \n const path = url.pathname.substring(1); // Remove leading /\n \n return join(this.dataDir, path);\n }\n\n /**\n * Read resource content from MCP URI.\n * Parses frontmatter for markdown files and detects MIME type.\n * \n * @param uri MCP URI\n * @returns Resource content with frontmatter and MIME type\n * @throws Error if file not found\n */\n read(uri: string): ResourceContent {\n const filePath = this.resolve(uri);\n \n if (!existsSync(filePath)) {\n // Helpful error for common mistake: extension-less task URIs\n if (/^mcp:\\/\\/backlog\\/tasks\\/(TASK|EPIC)-\\d+$/.test(uri)) {\n throw new Error(\n `Task URIs must include .md extension. Did you mean: ${uri}.md?`\n );\n }\n throw new Error(`Resource not found: ${uri} (resolved to ${filePath})`);\n }\n \n const content = readFileSync(filePath, 'utf-8');\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n const mimeType = this.getMimeType(ext);\n \n // Parse frontmatter for markdown files\n if (ext === 'md') {\n const parsed = matter(content);\n return {\n content: parsed.content,\n frontmatter: Object.keys(parsed.data).length > 0 ? parsed.data : undefined,\n mimeType,\n };\n }\n \n return {\n content,\n mimeType,\n };\n }\n\n /**\n * Check if a URI points to a task file.\n */\n private isTaskUri(uri: string): boolean {\n return /^mcp:\\/\\/backlog\\/tasks\\/(TASK|EPIC)-\\d+\\.md$/.test(uri);\n }\n\n /**\n * Update the updated_at timestamp in task frontmatter.\n */\n private updateTaskTimestamp(content: string): string {\n const parsed = matter(content);\n if (parsed.data && typeof parsed.data === 'object') {\n parsed.data.updated_at = new Date().toISOString();\n return matter.stringify(parsed.content, parsed.data);\n }\n return content;\n }\n\n /**\n * Write/modify resource content.\n * Applies operations like str_replace, append, insert, etc.\n * For task files, automatically updates the updated_at timestamp.\n * \n * @param uri MCP URI\n * @param operation Operation to apply\n * @returns Result with success status and message\n */\n write(uri: string, operation: Operation): WriteResourceResult {\n try {\n const filePath = this.resolve(uri);\n const canCreate = ['create', 'append', 'insert'].includes(operation.type);\n const isTask = this.isTaskUri(uri);\n \n if (!existsSync(filePath)) {\n if (canCreate) {\n // Auto-create file and parent directories\n mkdirSync(dirname(filePath), { recursive: true });\n writeFileSync(filePath, '', 'utf-8');\n } else {\n // str_replace/delete need existing content\n return {\n success: false,\n message: 'File not found',\n error: `Resource not found: ${uri} (${operation.type} requires existing file)`,\n };\n }\n }\n\n const fileContent = readFileSync(filePath, 'utf-8');\n let newContent = applyOperation(fileContent, operation);\n \n // Update timestamp for task files\n if (isTask) {\n newContent = this.updateTaskTimestamp(newContent);\n }\n \n writeFileSync(filePath, newContent, 'utf-8');\n\n return {\n success: true,\n message: `Successfully applied ${operation.type} to ${uri}`,\n };\n } catch (error) {\n return {\n success: false,\n message: 'Operation failed',\n error: error instanceof Error ? error.message : String(error),\n };\n }\n }\n\n /**\n * Convert file path to MCP URI.\n * Pure mapping: {dataDir}/path/file.md → mcp://backlog/path/file.md\n * \n * @param filePath Absolute file path\n * @returns MCP URI or null if file is outside data directory\n */\n toUri(filePath: string): string | null {\n if (!filePath.startsWith(this.dataDir)) {\n return null;\n }\n \n const relativePath = filePath.substring(this.dataDir.length + 1);\n return `mcp://backlog/${relativePath}`;\n }\n\n /**\n * Register MCP resource handler (catch-all pattern).\n */\n registerResource(server: McpServer) {\n const template = new ResourceTemplate(\n 'mcp://backlog/{+path}',\n { list: undefined }\n );\n \n server.registerResource(\n 'Data Directory Resource',\n template,\n { description: 'Any file in the backlog data directory' },\n async (uri) => {\n const resource = this.read(uri.toString());\n return { \n contents: [{ \n uri: uri.toString(), \n mimeType: resource.mimeType, \n text: resource.content \n }] \n };\n }\n );\n }\n\n /**\n * Register write_resource MCP tool.\n */\n registerWriteTool(server: McpServer) {\n server.registerTool(\n 'write_resource',\n {\n description: `A tool for creating and editing files on the MCP server\n * The \\`create\\` command will override the file at \\`uri\\` if it already exists as a file, and otherwise create a new file\n * The \\`append\\` command will add content to the end of a file, automatically adding a newline if the file doesn't end with one. Creates the file if it doesn't exist.\n Notes for using the \\`str_replace\\` command:\n * The \\`old_str\\` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n * If the \\`old_str\\` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in \\`old_str\\` to make it unique\n * The \\`new_str\\` parameter should contain the edited lines that should replace the \\`old_str\\``,\n inputSchema: z.object({\n uri: z.string().describe('MCP resource URI, e.g. mcp://backlog/path/to/file.md'),\n operation: z.preprocess(\n // Workaround: MCP clients stringify object params with $ref/oneOf schemas\n // https://github.com/anthropics/claude-code/issues/18260\n (val) => typeof val === 'string' ? JSON.parse(val) : val,\n z.discriminatedUnion('type', [\n z.object({\n type: z.literal('create'),\n file_text: z.string().describe('Content of the file to be created'),\n }),\n z.object({\n type: z.literal('str_replace'),\n old_str: z.string().describe('String in file to replace (must match exactly)'),\n new_str: z.string().describe('New string to replace old_str with'),\n }),\n z.object({\n type: z.literal('insert'),\n insert_line: z.number().describe('Line number after which new_str will be inserted'),\n new_str: z.string().describe('String to insert'),\n }),\n z.object({\n type: z.literal('append'),\n new_str: z.string().describe('Content to append to the file'),\n }),\n ])).describe('Operation to apply'),\n }),\n },\n async ({ uri, operation }) => {\n const result = this.write(uri, operation);\n return {\n content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],\n };\n }\n );\n }\n\n private getMimeType(ext: string): string {\n const mimeMap: Record<string, string> = {\n md: 'text/markdown',\n json: 'application/json',\n ts: 'text/typescript',\n js: 'application/javascript',\n txt: 'text/plain',\n };\n \n return mimeMap[ext] || 'text/plain';\n }\n}\n\n/**\n * Singleton instance for dependency injection.\n * Uses the configured backlog data directory.\n */\nexport const resourceManager = new ResourceManager(paths.backlogDataDir);\n"],"mappings":";;;;;;;;;;;;;AAoBA,SAAS,aAAa,SAAiB,UAA0B;AAE/D,QADc,QAAQ,MAAM,cAAc,GAC3B,IAAI,MAAM,IAAI,SAAS,QAAQ,SAAS,GAAG;;;;;;;;AAS5D,IAAa,kBAAb,MAA6B;CAC3B,YAAY,AAAiB,SAAiB;EAAjB;;;;;;CAM7B,OAAmB;EACjB,MAAM,eAAe,KAAK,KAAK,SAAS,YAAY;AACpD,MAAI,CAAC,WAAW,aAAa,CAAE,QAAO,EAAE;EAExC,MAAM,YAAwB,EAAE;AAChC,OAAK,cAAc,cAAc,UAAU;AAC3C,SAAO;;CAGT,AAAQ,cAAc,KAAa,WAA6B;EAC9D,MAAM,UAAU,YAAY,KAAK,EAAE,eAAe,MAAM,CAAC;AAEzD,OAAK,MAAM,SAAS,SAAS;GAC3B,MAAM,WAAW,KAAK,KAAK,MAAM,KAAK;AAEtC,OAAI,MAAM,aAAa,CACrB,MAAK,cAAc,UAAU,UAAU;YAC9B,MAAM,QAAQ,IAAI,MAAM,KAAK,SAAS,MAAM,CACrD,KAAI;IACF,MAAM,UAAU,aAAa,UAAU,QAAQ;IAC/C,MAAM,eAAe,SAAS,KAAK,SAAS,SAAS;IACrD,MAAM,MAAM,iBAAiB;AAE7B,cAAU,KAAK;KACb,IAAI;KACJ,MAAM;KACN,OAAO,aAAa,SAAS,MAAM,KAAK;KACxC;KACD,CAAC;WACI;;;;;;;;;;;CAed,QAAQ,KAAqB;AAC3B,MAAI,CAAC,IAAI,WAAW,SAAS,CAC3B,OAAM,IAAI,MAAM,mBAAmB,MAAM;AAI3C,MAAI,IAAI,SAAS,KAAK,CACpB,OAAM,IAAI,MAAM,+BAA+B,MAAM;EAGvD,MAAM,MAAM,IAAI,IAAI,IAAI;AAExB,MAAI,IAAI,aAAa,UACnB,OAAM,IAAI,MAAM,qBAAqB,IAAI,SAAS,sBAAsB;EAG1E,MAAM,OAAO,IAAI,SAAS,UAAU,EAAE;AAEtC,SAAO,KAAK,KAAK,SAAS,KAAK;;;;;;;;;;CAWjC,KAAK,KAA8B;EACjC,MAAM,WAAW,KAAK,QAAQ,IAAI;AAElC,MAAI,CAAC,WAAW,SAAS,EAAE;AAEzB,OAAI,4CAA4C,KAAK,IAAI,CACvD,OAAM,IAAI,MACR,uDAAuD,IAAI,MAC5D;AAEH,SAAM,IAAI,MAAM,uBAAuB,IAAI,gBAAgB,SAAS,GAAG;;EAGzE,MAAM,UAAU,aAAa,UAAU,QAAQ;EAC/C,MAAM,MAAM,SAAS,MAAM,IAAI,CAAC,KAAK,EAAE,aAAa,IAAI;EACxD,MAAM,WAAW,KAAK,YAAY,IAAI;AAGtC,MAAI,QAAQ,MAAM;GAChB,MAAM,SAAS,OAAO,QAAQ;AAC9B,UAAO;IACL,SAAS,OAAO;IAChB,aAAa,OAAO,KAAK,OAAO,KAAK,CAAC,SAAS,IAAI,OAAO,OAAO;IACjE;IACD;;AAGH,SAAO;GACL;GACA;GACD;;;;;CAMH,AAAQ,UAAU,KAAsB;AACtC,SAAO,gDAAgD,KAAK,IAAI;;;;;CAMlE,AAAQ,oBAAoB,SAAyB;EACnD,MAAM,SAAS,OAAO,QAAQ;AAC9B,MAAI,OAAO,QAAQ,OAAO,OAAO,SAAS,UAAU;AAClD,UAAO,KAAK,8BAAa,IAAI,MAAM,EAAC,aAAa;AACjD,UAAO,OAAO,UAAU,OAAO,SAAS,OAAO,KAAK;;AAEtD,SAAO;;;;;;;;;;;CAYT,MAAM,KAAa,WAA2C;AAC5D,MAAI;GACF,MAAM,WAAW,KAAK,QAAQ,IAAI;GAClC,MAAM,YAAY;IAAC;IAAU;IAAU;IAAS,CAAC,SAAS,UAAU,KAAK;GACzE,MAAM,SAAS,KAAK,UAAU,IAAI;AAElC,OAAI,CAAC,WAAW,SAAS,CACvB,KAAI,WAAW;AAEb,cAAU,QAAQ,SAAS,EAAE,EAAE,WAAW,MAAM,CAAC;AACjD,kBAAc,UAAU,IAAI,QAAQ;SAGpC,QAAO;IACL,SAAS;IACT,SAAS;IACT,OAAO,uBAAuB,IAAI,IAAI,UAAU,KAAK;IACtD;GAKL,IAAI,aAAa,eADG,aAAa,UAAU,QAAQ,EACN,UAAU;AAGvD,OAAI,OACF,cAAa,KAAK,oBAAoB,WAAW;AAGnD,iBAAc,UAAU,YAAY,QAAQ;AAE5C,UAAO;IACL,SAAS;IACT,SAAS,wBAAwB,UAAU,KAAK,MAAM;IACvD;WACM,OAAO;AACd,UAAO;IACL,SAAS;IACT,SAAS;IACT,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,MAAM;IAC9D;;;;;;;;;;CAWL,MAAM,UAAiC;AACrC,MAAI,CAAC,SAAS,WAAW,KAAK,QAAQ,CACpC,QAAO;AAIT,SAAO,iBADc,SAAS,UAAU,KAAK,QAAQ,SAAS,EAAE;;;;;CAOlE,iBAAiB,QAAmB;EAClC,MAAM,WAAW,IAAI,iBACnB,yBACA,EAAE,MAAM,QAAW,CACpB;AAED,SAAO,iBACL,2BACA,UACA,EAAE,aAAa,0CAA0C,EACzD,OAAO,QAAQ;GACb,MAAM,WAAW,KAAK,KAAK,IAAI,UAAU,CAAC;AAC1C,UAAO,EACL,UAAU,CAAC;IACT,KAAK,IAAI,UAAU;IACnB,UAAU,SAAS;IACnB,MAAM,SAAS;IAChB,CAAC,EACH;IAEJ;;;;;CAMH,kBAAkB,QAAmB;AACnC,SAAO,aACL,kBACA;GACE,aAAa;;;;;;;GAOb,aAAa,EAAE,OAAO;IACpB,KAAK,EAAE,QAAQ,CAAC,SAAS,uDAAuD;IAChF,WAAW,EAAE,YAGV,QAAQ,OAAO,QAAQ,WAAW,KAAK,MAAM,IAAI,GAAG,KACrD,EAAE,mBAAmB,QAAQ;KAC7B,EAAE,OAAO;MACP,MAAM,EAAE,QAAQ,SAAS;MACzB,WAAW,EAAE,QAAQ,CAAC,SAAS,oCAAoC;MACpE,CAAC;KACF,EAAE,OAAO;MACP,MAAM,EAAE,QAAQ,cAAc;MAC9B,SAAS,EAAE,QAAQ,CAAC,SAAS,iDAAiD;MAC9E,SAAS,EAAE,QAAQ,CAAC,SAAS,qCAAqC;MACnE,CAAC;KACF,EAAE,OAAO;MACP,MAAM,EAAE,QAAQ,SAAS;MACzB,aAAa,EAAE,QAAQ,CAAC,SAAS,mDAAmD;MACpF,SAAS,EAAE,QAAQ,CAAC,SAAS,mBAAmB;MACjD,CAAC;KACF,EAAE,OAAO;MACP,MAAM,EAAE,QAAQ,SAAS;MACzB,SAAS,EAAE,QAAQ,CAAC,SAAS,gCAAgC;MAC9D,CAAC;KACH,CAAC,CAAC,CAAC,SAAS,qBAAqB;IACnC,CAAC;GACH,EACD,OAAO,EAAE,KAAK,gBAAgB;GAC5B,MAAM,SAAS,KAAK,MAAM,KAAK,UAAU;AACzC,UAAO,EACL,SAAS,CAAC;IAAE,MAAM;IAAQ,MAAM,KAAK,UAAU,QAAQ,MAAM,EAAE;IAAE,CAAC,EACnE;IAEJ;;CAGH,AAAQ,YAAY,KAAqB;AASvC,SARwC;GACtC,IAAI;GACJ,MAAM;GACN,IAAI;GACJ,IAAI;GACJ,KAAK;GACN,CAEc,QAAQ;;;;;;;AAQ3B,MAAa,kBAAkB,IAAI,gBAAgB,MAAM,eAAe"}
|
|
1
|
+
{"version":3,"file":"manager.mjs","names":[],"sources":["../../src/resources/manager.ts"],"sourcesContent":["import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';\nimport { join, dirname, relative, basename } from 'node:path';\nimport matter from 'gray-matter';\nimport { z } from 'zod';\nimport { paths } from '@/utils/paths.js';\nimport { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport type { Operation, WriteResourceResult } from './types.js';\nimport { applyOperation } from './operations.js';\nimport type { Resource } from '@/search/types.js';\n\nexport interface ResourceContent {\n content: string;\n frontmatter?: Record<string, any>;\n mimeType: string;\n}\n\n/**\n * Extract title from markdown content.\n * Returns first # heading or filename without extension.\n */\nfunction extractTitle(content: string, filename: string): string {\n const match = content.match(/^#\\s+(.+)$/m);\n return match?.[1]?.trim() || filename.replace(/\\.md$/, '');\n}\n\n/**\n * ResourceManager - Single point of responsibility for MCP resource operations.\n * \n * Pure catch-all design: mcp://backlog/{+path} → {dataDir}/{path}\n * No special cases, no magic behavior.\n */\nexport class ResourceManager {\n constructor(private readonly dataDir: string) {}\n\n /**\n * List all resources in the resources/ directory.\n * Returns Resource objects ready for search indexing.\n */\n list(): Resource[] {\n const resourcesDir = join(this.dataDir, 'resources');\n if (!existsSync(resourcesDir)) return [];\n\n const resources: Resource[] = [];\n this.scanDirectory(resourcesDir, resources);\n return resources;\n }\n\n private scanDirectory(dir: string, resources: Resource[]): void {\n const entries = readdirSync(dir, { withFileTypes: true });\n \n for (const entry of entries) {\n const fullPath = join(dir, entry.name);\n \n if (entry.isDirectory()) {\n this.scanDirectory(fullPath, resources);\n } else if (entry.isFile() && entry.name.endsWith('.md')) {\n try {\n const content = readFileSync(fullPath, 'utf-8');\n const relativePath = relative(this.dataDir, fullPath);\n const uri = `mcp://backlog/${relativePath}`;\n \n resources.push({\n id: uri,\n path: relativePath,\n title: extractTitle(content, entry.name),\n content,\n });\n } catch {\n // Skip files that can't be read\n }\n }\n }\n }\n\n /**\n * Resolve MCP URI to absolute file path.\n * Pure catch-all: mcp://backlog/path/file.md → {dataDir}/path/file.md\n * \n * @param uri MCP URI (must start with mcp://backlog/)\n * @returns Absolute file path\n * @throws Error if URI is invalid or contains path traversal\n */\n resolve(uri: string): string {\n if (!uri.startsWith('mcp://')) {\n throw new Error(`Not an MCP URI: ${uri}`);\n }\n\n // Check for path traversal BEFORE URL parsing (URL normalizes ..)\n if (uri.includes('..')) {\n throw new Error(`Path traversal not allowed: ${uri}`);\n }\n\n const url = new URL(uri);\n \n if (url.hostname !== 'backlog') {\n throw new Error(`Invalid hostname: ${url.hostname}. Expected 'backlog'`);\n }\n \n const path = url.pathname.substring(1); // Remove leading /\n \n return join(this.dataDir, path);\n }\n\n /**\n * Read resource content from MCP URI.\n * Parses frontmatter for markdown files and detects MIME type.\n * \n * @param uri MCP URI\n * @returns Resource content with frontmatter and MIME type\n * @throws Error if file not found\n */\n read(uri: string): ResourceContent {\n const filePath = this.resolve(uri);\n \n if (!existsSync(filePath)) {\n // Helpful error for common mistake: extension-less task URIs\n if (/^mcp:\\/\\/backlog\\/tasks\\/(TASK|EPIC)-\\d+$/.test(uri)) {\n throw new Error(\n `Task URIs must include .md extension. Did you mean: ${uri}.md?`\n );\n }\n throw new Error(`Resource not found: ${uri} (resolved to ${filePath})`);\n }\n \n const content = readFileSync(filePath, 'utf-8');\n const ext = filePath.split('.').pop()?.toLowerCase() || 'txt';\n const mimeType = this.getMimeType(ext);\n \n // Parse frontmatter for markdown files\n if (ext === 'md') {\n const parsed = matter(content);\n return {\n content: parsed.content,\n frontmatter: Object.keys(parsed.data).length > 0 ? parsed.data : undefined,\n mimeType,\n };\n }\n \n return {\n content,\n mimeType,\n };\n }\n\n /**\n * Check if a URI points to a task file.\n */\n private isTaskUri(uri: string): boolean {\n return /^mcp:\\/\\/backlog\\/tasks\\/(TASK|EPIC)-\\d+\\.md$/.test(uri);\n }\n\n /**\n * Update the updated_at timestamp in task frontmatter.\n */\n private updateTaskTimestamp(content: string): string {\n const parsed = matter(content);\n if (parsed.data && typeof parsed.data === 'object') {\n parsed.data.updated_at = new Date().toISOString();\n return matter.stringify(parsed.content, parsed.data);\n }\n return content;\n }\n\n /**\n * Write/modify resource content.\n * Applies operations like str_replace, append, insert, etc.\n * For task files, automatically updates the updated_at timestamp.\n * \n * @param uri MCP URI\n * @param operation Operation to apply\n * @returns Result with success status and message\n */\n write(uri: string, operation: Operation): WriteResourceResult {\n try {\n const filePath = this.resolve(uri);\n const canCreate = ['create', 'append', 'insert'].includes(operation.type);\n const isTask = this.isTaskUri(uri);\n \n if (!existsSync(filePath)) {\n if (canCreate) {\n // Auto-create file and parent directories\n mkdirSync(dirname(filePath), { recursive: true });\n writeFileSync(filePath, '', 'utf-8');\n } else {\n // str_replace/delete need existing content\n return {\n success: false,\n message: 'File not found',\n error: `Resource not found: ${uri} (${operation.type} requires existing file)`,\n };\n }\n }\n\n const fileContent = readFileSync(filePath, 'utf-8');\n\n // Prevent create from overwriting existing task files — use str_replace or backlog_update instead\n if (isTask && operation.type === 'create' && fileContent) {\n return {\n success: false,\n message: 'Cannot overwrite existing task file',\n error: `${uri} already exists. Use str_replace to edit content, or backlog_update to update metadata.`,\n };\n }\n\n let newContent = applyOperation(fileContent, operation);\n \n // Update timestamp for task files\n if (isTask) {\n newContent = this.updateTaskTimestamp(newContent);\n }\n \n writeFileSync(filePath, newContent, 'utf-8');\n\n return {\n success: true,\n message: `Successfully applied ${operation.type} to ${uri}`,\n };\n } catch (error) {\n return {\n success: false,\n message: 'Operation failed',\n error: error instanceof Error ? error.message : String(error),\n };\n }\n }\n\n /**\n * Convert file path to MCP URI.\n * Pure mapping: {dataDir}/path/file.md → mcp://backlog/path/file.md\n * \n * @param filePath Absolute file path\n * @returns MCP URI or null if file is outside data directory\n */\n toUri(filePath: string): string | null {\n if (!filePath.startsWith(this.dataDir)) {\n return null;\n }\n \n const relativePath = filePath.substring(this.dataDir.length + 1);\n return `mcp://backlog/${relativePath}`;\n }\n\n /**\n * Register MCP resource handler (catch-all pattern).\n */\n registerResource(server: McpServer) {\n const template = new ResourceTemplate(\n 'mcp://backlog/{+path}',\n { list: undefined }\n );\n \n server.registerResource(\n 'Data Directory Resource',\n template,\n { description: 'Any file in the backlog data directory' },\n async (uri) => {\n const resource = this.read(uri.toString());\n return { \n contents: [{ \n uri: uri.toString(), \n mimeType: resource.mimeType, \n text: resource.content \n }] \n };\n }\n );\n }\n\n /**\n * Register write_resource MCP tool.\n */\n registerWriteTool(server: McpServer) {\n server.registerTool(\n 'write_resource',\n {\n description: `A tool for creating and editing files on the MCP server\n * The \\`create\\` command will override the file at \\`uri\\` if it already exists as a file, and otherwise create a new file\n * The \\`append\\` command will add content to the end of a file, automatically adding a newline if the file doesn't end with one. Creates the file if it doesn't exist.\n Notes for using the \\`str_replace\\` command:\n * The \\`old_str\\` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n * If the \\`old_str\\` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in \\`old_str\\` to make it unique\n * The \\`new_str\\` parameter should contain the edited lines that should replace the \\`old_str\\``,\n inputSchema: z.object({\n uri: z.string().describe('MCP resource URI, e.g. mcp://backlog/path/to/file.md'),\n operation: z.preprocess(\n // Workaround: MCP clients stringify object params with $ref/oneOf schemas\n // https://github.com/anthropics/claude-code/issues/18260\n (val) => typeof val === 'string' ? JSON.parse(val) : val,\n z.discriminatedUnion('type', [\n z.object({\n type: z.literal('create'),\n file_text: z.string().describe('Content of the file to be created'),\n }),\n z.object({\n type: z.literal('str_replace'),\n old_str: z.string().describe('String in file to replace (must match exactly)'),\n new_str: z.string().describe('New string to replace old_str with'),\n }),\n z.object({\n type: z.literal('insert'),\n insert_line: z.number().describe('Line number after which new_str will be inserted'),\n new_str: z.string().describe('String to insert'),\n }),\n z.object({\n type: z.literal('append'),\n new_str: z.string().describe('Content to append to the file'),\n }),\n ])).describe('Operation to apply'),\n }),\n },\n async ({ uri, operation }) => {\n const result = this.write(uri, operation);\n return {\n content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],\n };\n }\n );\n }\n\n private getMimeType(ext: string): string {\n const mimeMap: Record<string, string> = {\n md: 'text/markdown',\n json: 'application/json',\n ts: 'text/typescript',\n js: 'application/javascript',\n txt: 'text/plain',\n };\n \n return mimeMap[ext] || 'text/plain';\n }\n}\n\n/**\n * Singleton instance for dependency injection.\n * Uses the configured backlog data directory.\n */\nexport const resourceManager = new ResourceManager(paths.backlogDataDir);\n"],"mappings":"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"}
|
|
@@ -295,7 +295,8 @@ var OramaSearchService = class {
|
|
|
295
295
|
if (filters) {
|
|
296
296
|
if (filters.status?.length) hits = hits.filter((h) => filters.status.includes(h.task.status));
|
|
297
297
|
if (filters.type) hits = hits.filter((h) => (h.task.type || "task") === filters.type);
|
|
298
|
-
if (filters.epic_id) hits = hits.filter((h) => h.task.epic_id === filters.epic_id);
|
|
298
|
+
if (filters.epic_id) hits = hits.filter((h) => (h.task.parent_id ?? h.task.epic_id) === filters.epic_id);
|
|
299
|
+
if (filters.parent_id) hits = hits.filter((h) => (h.task.parent_id ?? h.task.epic_id) === filters.parent_id);
|
|
299
300
|
}
|
|
300
301
|
hits = rerankWithSignals(hits.map((h) => ({
|
|
301
302
|
score: h.score,
|
|
@@ -475,7 +476,8 @@ var OramaSearchService = class {
|
|
|
475
476
|
const task = h.item;
|
|
476
477
|
if (filters.status?.length && !filters.status.includes(task.status)) return false;
|
|
477
478
|
if (filters.type && (task.type || "task") !== filters.type) return false;
|
|
478
|
-
if (filters.epic_id && task.epic_id !== filters.epic_id) return false;
|
|
479
|
+
if (filters.epic_id && (task.parent_id ?? task.epic_id) !== filters.epic_id) return false;
|
|
480
|
+
if (filters.parent_id && (task.parent_id ?? task.epic_id) !== filters.parent_id) return false;
|
|
479
481
|
return true;
|
|
480
482
|
});
|
|
481
483
|
if (sortMode === "recent") hits.sort((a, b) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"orama-search-service.mjs","names":[],"sources":["../../src/search/orama-search-service.ts"],"sourcesContent":["import { create, insert, remove, search, save, load, type Orama, type Results, type Tokenizer } from '@orama/orama';\nimport { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';\nimport { dirname } from 'node:path';\nimport type { Task } from '@/storage/schema.js';\nimport type { SearchService, SearchOptions, SearchResult, Resource, ResourceSearchResult, SearchableType } from './types.js';\nimport { EmbeddingService, EMBEDDING_DIMENSIONS } from './embedding-service.js';\n\ntype OramaDoc = {\n id: string;\n title: string;\n description: string;\n status: string;\n type: string;\n epic_id: string;\n evidence: string;\n blocked_reason: string;\n references: string;\n path: string; // For resources: relative path\n};\n\ntype OramaDocWithEmbeddings = OramaDoc & {\n embeddings: number[];\n};\n\nconst schema = {\n id: 'string',\n title: 'string',\n description: 'string',\n status: 'string',\n type: 'string',\n epic_id: 'string',\n evidence: 'string',\n blocked_reason: 'string',\n references: 'string',\n path: 'string',\n} as const;\n\nconst schemaWithEmbeddings = {\n ...schema,\n embeddings: `vector[${EMBEDDING_DIMENSIONS}]`,\n} as const;\n\ntype OramaInstance = Orama<typeof schema>;\ntype OramaInstanceWithEmbeddings = Orama<typeof schemaWithEmbeddings>;\n\nexport interface OramaSearchOptions {\n cachePath: string;\n /** Enable hybrid search with local embeddings. Default: true */\n hybridSearch?: boolean;\n}\n\n/**\n * Custom tokenizer that expands hyphenated words while preserving originals.\n */\nconst hyphenAwareTokenizer: Tokenizer = {\n language: 'english',\n normalizationCache: new Map(),\n tokenize(input: string): string[] {\n if (typeof input !== 'string') return [];\n const tokens = input.toLowerCase().split(/[^a-z0-9'-]+/gi).filter(Boolean);\n const expanded: string[] = [];\n for (const token of tokens) {\n expanded.push(token);\n if (token.includes('-')) {\n expanded.push(...token.split(/-+/).filter(Boolean));\n }\n }\n return [...new Set(expanded)];\n },\n};\n\n/**\n * Ranking signal bonus values for re-ranking (ADR-0051).\n * Multiple signals combine additively to determine final ranking.\n * \n * Bonus values are tuned so that:\n * - Title matches rank above description-only matches\n * - Title-starts-with-query gets highest priority (strongest signal)\n * - Matching more query words gives additional bonus\n * - Epics get a small boost only when they have strong title matches\n * - Recent items get a boost but don't overwhelm relevance\n */\nconst RANKING_BONUS = {\n // Title match bonuses (highest priority)\n TITLE_STARTS_WITH: 20, // Title starts with query (strongest signal)\n TITLE_EXACT_WORD: 10, // Query appears as standalone word in title\n TITLE_PARTIAL: 3, // Query appears as substring in title (compound word)\n // Multi-word match bonus\n MULTI_WORD_MATCH: 8, // Per additional query word matched in title\n // Type importance bonus - only applied with title match\n EPIC_WITH_TITLE_MATCH: 5, // Epics with title match get extra boost\n // Recency bonuses (decayed by age)\n RECENCY_TODAY: 5, // Updated today\n RECENCY_WEEK: 3, // Updated this week\n RECENCY_MONTH: 2, // Updated this month\n RECENCY_QUARTER: 1, // Updated this quarter\n};\n\n/**\n * Calculate recency bonus based on days since last update.\n */\nfunction getRecencyBonus(updatedAt: string | undefined): number {\n if (!updatedAt) return 0;\n const daysSinceUpdate = (Date.now() - new Date(updatedAt).getTime()) / (1000 * 60 * 60 * 24);\n if (daysSinceUpdate < 1) return RANKING_BONUS.RECENCY_TODAY;\n if (daysSinceUpdate < 7) return RANKING_BONUS.RECENCY_WEEK;\n if (daysSinceUpdate < 30) return RANKING_BONUS.RECENCY_MONTH;\n if (daysSinceUpdate < 90) return RANKING_BONUS.RECENCY_QUARTER;\n return 0;\n}\n\n/**\n * Re-rank results using multiple signals: title match quality, type importance, recency.\n * Adds fixed bonuses to ensure important items rank appropriately.\n * \n * @param results - Search results with score and item (task or resource)\n * @param query - Original search query\n * @returns Re-ranked results sorted by adjusted score\n */\nfunction rerankWithSignals<T extends { score: number; item: { title?: string; type?: string; updated_at?: string } }>(\n results: T[],\n query: string\n): T[] {\n if (!query.trim()) return results;\n \n const queryLower = query.toLowerCase().trim();\n const queryWords = queryLower.split(/\\s+/);\n \n return results.map(r => {\n let bonus = 0;\n \n // Title match bonus - check in order of strength\n const title = r.item.title?.toLowerCase() || '';\n const titleWords = title.split(/\\W+/).filter(Boolean);\n \n // Count how many query words match in title\n const matchingQueryWords = queryWords.filter(qw => titleWords.includes(qw));\n const matchCount = matchingQueryWords.length;\n \n // Strongest: title starts with query (e.g., \"Backlog MCP\" for query \"backlog\")\n const titleStartsWithQuery = queryWords.some(qw => title.startsWith(qw));\n \n // Strong: query word appears as standalone word in title\n const hasExactMatch = matchCount > 0;\n \n // Weak: query appears as substring in title (compound word)\n const hasPartialMatch = !hasExactMatch && queryWords.some(qw => title.includes(qw));\n \n // Track if we have any title match for epic bonus\n const hasTitleMatch = titleStartsWithQuery || hasExactMatch || hasPartialMatch;\n \n if (titleStartsWithQuery) {\n bonus += RANKING_BONUS.TITLE_STARTS_WITH;\n } else if (hasExactMatch) {\n bonus += RANKING_BONUS.TITLE_EXACT_WORD;\n } else if (hasPartialMatch) {\n bonus += RANKING_BONUS.TITLE_PARTIAL;\n }\n \n // Multi-word match bonus: reward matching more query words in title\n // Only count additional words beyond the first match\n if (matchCount > 1) {\n bonus += (matchCount - 1) * RANKING_BONUS.MULTI_WORD_MATCH;\n }\n \n // Type importance bonus - only for epics WITH title match\n // This prevents epics from ranking above tasks when they only match in description\n if (r.item.type === 'epic' && hasTitleMatch) {\n bonus += RANKING_BONUS.EPIC_WITH_TITLE_MATCH;\n }\n \n // Recency bonus\n bonus += getRecencyBonus(r.item.updated_at);\n \n return { ...r, score: r.score + bonus };\n }).sort((a, b) => b.score - a.score);\n}\n\n/**\n * Orama-backed search service with optional hybrid search (BM25 + vector).\n * Gracefully falls back to BM25-only if embeddings fail to load.\n */\nexport class OramaSearchService implements SearchService {\n private db: OramaInstance | OramaInstanceWithEmbeddings | null = null;\n private taskCache = new Map<string, Task>();\n private resourceCache = new Map<string, Resource>();\n private saveTimeout: ReturnType<typeof setTimeout> | null = null;\n private readonly cachePath: string;\n\n // Embedding state\n private readonly hybridEnabled: boolean;\n private embedder: EmbeddingService | null = null;\n private embeddingsReady = false;\n private embeddingsInitPromise: Promise<boolean> | null = null;\n private hasEmbeddingsInIndex = false;\n\n constructor(options: OramaSearchOptions) {\n this.cachePath = options.cachePath;\n this.hybridEnabled = options.hybridSearch ?? true;\n }\n\n private get indexPath(): string {\n return this.cachePath;\n }\n\n /**\n * Lazy-load embedding service. Returns true if embeddings are available.\n */\n private async ensureEmbeddings(): Promise<boolean> {\n if (!this.hybridEnabled) return false;\n if (this.embeddingsReady) return true;\n if (this.embeddingsInitPromise) return this.embeddingsInitPromise;\n\n this.embeddingsInitPromise = (async () => {\n try {\n this.embedder = new EmbeddingService();\n await this.embedder.init();\n this.embeddingsReady = true;\n return true;\n } catch (e) {\n // Graceful fallback - embeddings unavailable, use BM25 only\n this.embedder = null;\n this.embeddingsReady = false;\n return false;\n }\n })();\n\n return this.embeddingsInitPromise;\n }\n\n private getTextForEmbedding(task: Task): string {\n return `${task.title} ${task.description || ''}`.trim();\n }\n\n private taskToDoc(task: Task): OramaDoc {\n return {\n id: task.id,\n title: task.title,\n description: task.description || '',\n status: task.status,\n type: task.type || 'task',\n epic_id: task.epic_id || '',\n evidence: (task.evidence || []).join(' '),\n blocked_reason: (task.blocked_reason || []).join(' '),\n references: (task.references || []).map(r => `${r.title || ''} ${r.url}`).join(' '),\n path: '', // Tasks don't have paths\n };\n }\n\n private resourceToDoc(resource: Resource): OramaDoc {\n return {\n id: resource.id,\n title: resource.title,\n description: resource.content, // Full content for search\n status: '',\n type: 'resource',\n epic_id: '',\n evidence: '',\n blocked_reason: '',\n references: '',\n path: resource.path,\n };\n }\n\n private getResourceTextForEmbedding(resource: Resource): string {\n return `${resource.title} ${resource.content}`.trim();\n }\n\n private async resourceToDocWithEmbeddings(resource: Resource): Promise<OramaDocWithEmbeddings> {\n const doc = this.resourceToDoc(resource);\n const text = this.getResourceTextForEmbedding(resource);\n const embeddings = await this.embedder!.embed(text);\n return { ...doc, embeddings };\n }\n\n private async taskToDocWithEmbeddings(task: Task): Promise<OramaDocWithEmbeddings> {\n const doc = this.taskToDoc(task);\n const text = this.getTextForEmbedding(task);\n const embeddings = await this.embedder!.embed(text);\n return { ...doc, embeddings };\n }\n\n private scheduleSave(): void {\n if (this.saveTimeout) clearTimeout(this.saveTimeout);\n this.saveTimeout = setTimeout(() => this.persistToDisk(), 1000);\n }\n\n private persistToDisk(): void {\n if (!this.db) return;\n try {\n const dir = dirname(this.indexPath);\n if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n const data = save(this.db);\n const serialized = JSON.stringify({\n index: data,\n tasks: Object.fromEntries(this.taskCache),\n resources: Object.fromEntries(this.resourceCache),\n hasEmbeddings: this.hasEmbeddingsInIndex,\n });\n writeFileSync(this.indexPath, serialized);\n } catch {\n // Ignore persistence errors - index will rebuild on next start\n }\n }\n\n private async loadFromDisk(): Promise<boolean> {\n try {\n if (!existsSync(this.indexPath)) return false;\n const raw = JSON.parse(readFileSync(this.indexPath, 'utf-8'));\n\n // Check if cached index has embeddings\n this.hasEmbeddingsInIndex = raw.hasEmbeddings ?? false;\n\n const schemaToUse = this.hasEmbeddingsInIndex ? schemaWithEmbeddings : schema;\n this.db = await create({ schema: schemaToUse, components: { tokenizer: hyphenAwareTokenizer } });\n load(this.db, raw.index);\n this.taskCache = new Map(Object.entries(raw.tasks as Record<string, Task>));\n this.resourceCache = new Map(Object.entries((raw.resources || {}) as Record<string, Resource>));\n return true;\n } catch {\n return false;\n }\n }\n\n async index(tasks: Task[]): Promise<void> {\n // Try loading from disk first\n if (await this.loadFromDisk()) return;\n\n // Check if embeddings are available for fresh index\n const useEmbeddings = await this.ensureEmbeddings();\n\n // Build fresh index\n const schemaToUse = useEmbeddings ? schemaWithEmbeddings : schema;\n this.db = await create({ schema: schemaToUse, components: { tokenizer: hyphenAwareTokenizer } });\n this.taskCache.clear();\n this.hasEmbeddingsInIndex = useEmbeddings;\n\n for (const task of tasks) {\n this.taskCache.set(task.id, task);\n if (useEmbeddings) {\n const doc = await this.taskToDocWithEmbeddings(task);\n await insert(this.db as OramaInstanceWithEmbeddings, doc);\n } else {\n await insert(this.db as OramaInstance, this.taskToDoc(task));\n }\n }\n this.persistToDisk();\n }\n\n async search(query: string, options?: SearchOptions): Promise<SearchResult[]> {\n if (!this.db) return [];\n if (!query.trim()) return [];\n\n const limit = options?.limit ?? 20;\n const boost = options?.boost ?? { id: 10, title: 2 };\n\n // Determine if we can use hybrid search\n const canUseHybrid = this.hasEmbeddingsInIndex && (await this.ensureEmbeddings());\n\n let results: Results<OramaDoc | OramaDocWithEmbeddings>;\n\n if (canUseHybrid) {\n // Hybrid search: BM25 + vector\n const queryVector = await this.embedder!.embed(query);\n results = await search(this.db as OramaInstanceWithEmbeddings, {\n term: query,\n mode: 'hybrid',\n vector: {\n value: queryVector,\n property: 'embeddings',\n },\n // Prioritize BM25 (exact/fuzzy matches) over vector (semantic)\n // This ensures exact matches rank highest while semantic matches are still found\n hybridWeights: { text: 0.8, vector: 0.2 },\n similarity: 0.2, // Low threshold to catch semantic matches\n limit,\n boost,\n tolerance: 1,\n });\n } else {\n // BM25 only\n results = await search(this.db, {\n term: query,\n limit,\n boost,\n tolerance: 1,\n });\n }\n\n let hits = results.hits.map(hit => ({\n id: hit.document.id,\n score: hit.score,\n task: this.taskCache.get(hit.document.id)!,\n }));\n\n // Apply filters post-search\n const filters = options?.filters;\n if (filters) {\n if (filters.status?.length) {\n hits = hits.filter(h => filters.status!.includes(h.task.status));\n }\n if (filters.type) {\n hits = hits.filter(h => (h.task.type || 'task') === filters.type);\n }\n if (filters.epic_id) {\n hits = hits.filter(h => h.task.epic_id === filters.epic_id);\n }\n }\n\n // Re-rank with multiple signals: title match, type importance, recency (ADR-0051)\n const reranked = rerankWithSignals(\n hits.map(h => ({ score: h.score, item: h.task })),\n query\n );\n hits = reranked.map((r, i) => ({\n id: hits.find(h => h.task === r.item)!.id,\n score: r.score,\n task: r.item,\n }));\n\n return hits.slice(0, limit);\n }\n\n async addDocument(task: Task): Promise<void> {\n if (!this.db) return;\n this.taskCache.set(task.id, task);\n\n try {\n if (this.hasEmbeddingsInIndex && this.embeddingsReady) {\n const doc = await this.taskToDocWithEmbeddings(task);\n await insert(this.db as OramaInstanceWithEmbeddings, doc);\n } else {\n await insert(this.db as OramaInstance, this.taskToDoc(task));\n }\n } catch (e: any) {\n if (e?.code === 'DOCUMENT_ALREADY_EXISTS') {\n await this.updateDocument(task);\n return;\n }\n throw e;\n }\n this.scheduleSave();\n }\n\n async removeDocument(id: string): Promise<void> {\n if (!this.db) return;\n this.taskCache.delete(id);\n try {\n await remove(this.db, id);\n this.scheduleSave();\n } catch {\n // Ignore if document doesn't exist\n }\n }\n\n async updateDocument(task: Task): Promise<void> {\n await this.removeDocument(task.id);\n this.taskCache.set(task.id, task);\n\n if (this.hasEmbeddingsInIndex && this.embeddingsReady) {\n const doc = await this.taskToDocWithEmbeddings(task);\n await insert(this.db as OramaInstanceWithEmbeddings, doc);\n } else {\n await insert(this.db as OramaInstance, this.taskToDoc(task));\n }\n this.scheduleSave();\n }\n\n /**\n * Check if hybrid search is currently active.\n */\n isHybridSearchActive(): boolean {\n return this.hasEmbeddingsInIndex && this.embeddingsReady;\n }\n\n /**\n * Index resources into the search index.\n * Should be called after index() to add resources to existing index.\n */\n async indexResources(resources: Resource[]): Promise<void> {\n if (!this.db) return;\n\n for (const resource of resources) {\n this.resourceCache.set(resource.id, resource);\n try {\n if (this.hasEmbeddingsInIndex && this.embeddingsReady) {\n const doc = await this.resourceToDocWithEmbeddings(resource);\n await insert(this.db as OramaInstanceWithEmbeddings, doc);\n } else {\n await insert(this.db as OramaInstance, this.resourceToDoc(resource));\n }\n } catch (e: any) {\n if (e?.code === 'DOCUMENT_ALREADY_EXISTS') {\n // Update existing resource\n await this.updateResource(resource);\n }\n // Ignore other errors - continue indexing\n }\n }\n this.scheduleSave();\n }\n\n /**\n * Search for resources only.\n */\n async searchResources(query: string, options?: { limit?: number }): Promise<ResourceSearchResult[]> {\n if (!this.db) return [];\n if (!query.trim()) return [];\n\n const limit = options?.limit ?? 20;\n const boost = { title: 2, description: 1 };\n\n const canUseHybrid = this.hasEmbeddingsInIndex && (await this.ensureEmbeddings());\n\n let results: Results<OramaDoc | OramaDocWithEmbeddings>;\n\n if (canUseHybrid) {\n const queryVector = await this.embedder!.embed(query);\n results = await search(this.db as OramaInstanceWithEmbeddings, {\n term: query,\n mode: 'hybrid',\n vector: { value: queryVector, property: 'embeddings' },\n hybridWeights: { text: 0.8, vector: 0.2 },\n similarity: 0.2,\n limit: limit * 3, // Fetch more to filter\n boost,\n tolerance: 1,\n });\n } else {\n results = await search(this.db, {\n term: query,\n limit: limit * 3,\n boost,\n tolerance: 1,\n });\n }\n\n // Filter to resources only\n let resourceHits = results.hits\n .filter(hit => hit.document.type === 'resource')\n .map(hit => ({\n id: hit.document.id,\n score: hit.score,\n resource: this.resourceCache.get(hit.document.id)!,\n }))\n .filter(h => h.resource);\n\n // Re-rank with multiple signals (ADR-0051) - resources don't have type/recency, just title\n const reranked = rerankWithSignals(\n resourceHits.map(h => ({ score: h.score, item: h.resource })),\n query\n );\n resourceHits = reranked.map(r => ({\n id: (r.item as Resource).id,\n score: r.score,\n resource: r.item as Resource,\n }));\n\n return resourceHits.slice(0, limit);\n }\n\n /**\n * Search all document types with optional type filtering.\n * Returns results sorted by relevance across all types.\n */\n async searchAll(query: string, options?: SearchOptions): Promise<Array<{ id: string; score: number; type: SearchableType; item: Task | Resource }>> {\n if (!this.db) return [];\n if (!query.trim()) return [];\n\n const limit = options?.limit ?? 20;\n const docTypes = options?.docTypes;\n const boost = options?.boost ?? { id: 10, title: 2 };\n const sortMode = options?.sort ?? 'relevant';\n\n const canUseHybrid = this.hasEmbeddingsInIndex && (await this.ensureEmbeddings());\n\n let results: Results<OramaDoc | OramaDocWithEmbeddings>;\n\n if (canUseHybrid) {\n const queryVector = await this.embedder!.embed(query);\n results = await search(this.db as OramaInstanceWithEmbeddings, {\n term: query,\n mode: 'hybrid',\n vector: { value: queryVector, property: 'embeddings' },\n hybridWeights: { text: 0.8, vector: 0.2 },\n similarity: 0.2,\n limit: limit * 3,\n boost,\n tolerance: 1,\n });\n } else {\n results = await search(this.db, {\n term: query,\n limit: limit * 3,\n boost,\n tolerance: 1,\n });\n }\n\n let hits = results.hits.map(hit => {\n const docType = hit.document.type as SearchableType;\n const isResource = docType === 'resource';\n return {\n id: hit.document.id,\n score: hit.score,\n type: docType,\n item: isResource \n ? this.resourceCache.get(hit.document.id)! \n : this.taskCache.get(hit.document.id)!,\n };\n }).filter(h => h.item);\n\n // Filter by document types if specified\n if (docTypes?.length) {\n hits = hits.filter(h => docTypes.includes(h.type));\n }\n\n // Apply task-specific filters\n const filters = options?.filters;\n if (filters) {\n hits = hits.filter(h => {\n if (h.type === 'resource') return true; // Resources don't have these filters\n const task = h.item as Task;\n if (filters.status?.length && !filters.status.includes(task.status)) return false;\n if (filters.type && (task.type || 'task') !== filters.type) return false;\n if (filters.epic_id && task.epic_id !== filters.epic_id) return false;\n return true;\n });\n }\n\n // Sort based on mode\n if (sortMode === 'recent') {\n // Sort by updated_at descending (most recent first)\n hits.sort((a, b) => {\n const aDate = (a.item as Task).updated_at || '';\n const bDate = (b.item as Task).updated_at || '';\n return bDate.localeCompare(aDate);\n });\n } else {\n // Re-rank with multiple signals: title match, type importance, recency (ADR-0051)\n hits = rerankWithSignals(hits, query);\n }\n\n return hits.slice(0, limit);\n }\n\n async addResource(resource: Resource): Promise<void> {\n if (!this.db) return;\n this.resourceCache.set(resource.id, resource);\n\n try {\n if (this.hasEmbeddingsInIndex && this.embeddingsReady) {\n const doc = await this.resourceToDocWithEmbeddings(resource);\n await insert(this.db as OramaInstanceWithEmbeddings, doc);\n } else {\n await insert(this.db as OramaInstance, this.resourceToDoc(resource));\n }\n } catch (e: any) {\n if (e?.code === 'DOCUMENT_ALREADY_EXISTS') {\n await this.updateResource(resource);\n return;\n }\n throw e;\n }\n this.scheduleSave();\n }\n\n async removeResource(id: string): Promise<void> {\n if (!this.db) return;\n this.resourceCache.delete(id);\n try {\n await remove(this.db, id);\n this.scheduleSave();\n } catch {\n // Ignore if document doesn't exist\n }\n }\n\n async updateResource(resource: Resource): Promise<void> {\n await this.removeResource(resource.id);\n this.resourceCache.set(resource.id, resource);\n\n if (this.hasEmbeddingsInIndex && this.embeddingsReady) {\n const doc = await this.resourceToDocWithEmbeddings(resource);\n await insert(this.db as OramaInstanceWithEmbeddings, doc);\n } else {\n await insert(this.db as OramaInstance, this.resourceToDoc(resource));\n }\n this.scheduleSave();\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"orama-search-service.mjs","names":[],"sources":["../../src/search/orama-search-service.ts"],"sourcesContent":["import { create, insert, remove, search, save, load, type Orama, type Results, type Tokenizer } from '@orama/orama';\nimport { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';\nimport { dirname } from 'node:path';\nimport type { Task } from '@/storage/schema.js';\nimport type { SearchService, SearchOptions, SearchResult, Resource, ResourceSearchResult, SearchableType } from './types.js';\nimport { EmbeddingService, EMBEDDING_DIMENSIONS } from './embedding-service.js';\n\ntype OramaDoc = {\n id: string;\n title: string;\n description: string;\n status: string;\n type: string;\n epic_id: string;\n evidence: string;\n blocked_reason: string;\n references: string;\n path: string; // For resources: relative path\n};\n\ntype OramaDocWithEmbeddings = OramaDoc & {\n embeddings: number[];\n};\n\nconst schema = {\n id: 'string',\n title: 'string',\n description: 'string',\n status: 'string',\n type: 'string',\n epic_id: 'string',\n evidence: 'string',\n blocked_reason: 'string',\n references: 'string',\n path: 'string',\n} as const;\n\nconst schemaWithEmbeddings = {\n ...schema,\n embeddings: `vector[${EMBEDDING_DIMENSIONS}]`,\n} as const;\n\ntype OramaInstance = Orama<typeof schema>;\ntype OramaInstanceWithEmbeddings = Orama<typeof schemaWithEmbeddings>;\n\nexport interface OramaSearchOptions {\n cachePath: string;\n /** Enable hybrid search with local embeddings. Default: true */\n hybridSearch?: boolean;\n}\n\n/**\n * Custom tokenizer that expands hyphenated words while preserving originals.\n */\nconst hyphenAwareTokenizer: Tokenizer = {\n language: 'english',\n normalizationCache: new Map(),\n tokenize(input: string): string[] {\n if (typeof input !== 'string') return [];\n const tokens = input.toLowerCase().split(/[^a-z0-9'-]+/gi).filter(Boolean);\n const expanded: string[] = [];\n for (const token of tokens) {\n expanded.push(token);\n if (token.includes('-')) {\n expanded.push(...token.split(/-+/).filter(Boolean));\n }\n }\n return [...new Set(expanded)];\n },\n};\n\n/**\n * Ranking signal bonus values for re-ranking (ADR-0051).\n * Multiple signals combine additively to determine final ranking.\n * \n * Bonus values are tuned so that:\n * - Title matches rank above description-only matches\n * - Title-starts-with-query gets highest priority (strongest signal)\n * - Matching more query words gives additional bonus\n * - Epics get a small boost only when they have strong title matches\n * - Recent items get a boost but don't overwhelm relevance\n */\nconst RANKING_BONUS = {\n // Title match bonuses (highest priority)\n TITLE_STARTS_WITH: 20, // Title starts with query (strongest signal)\n TITLE_EXACT_WORD: 10, // Query appears as standalone word in title\n TITLE_PARTIAL: 3, // Query appears as substring in title (compound word)\n // Multi-word match bonus\n MULTI_WORD_MATCH: 8, // Per additional query word matched in title\n // Type importance bonus - only applied with title match\n EPIC_WITH_TITLE_MATCH: 5, // Epics with title match get extra boost\n // Recency bonuses (decayed by age)\n RECENCY_TODAY: 5, // Updated today\n RECENCY_WEEK: 3, // Updated this week\n RECENCY_MONTH: 2, // Updated this month\n RECENCY_QUARTER: 1, // Updated this quarter\n};\n\n/**\n * Calculate recency bonus based on days since last update.\n */\nfunction getRecencyBonus(updatedAt: string | undefined): number {\n if (!updatedAt) return 0;\n const daysSinceUpdate = (Date.now() - new Date(updatedAt).getTime()) / (1000 * 60 * 60 * 24);\n if (daysSinceUpdate < 1) return RANKING_BONUS.RECENCY_TODAY;\n if (daysSinceUpdate < 7) return RANKING_BONUS.RECENCY_WEEK;\n if (daysSinceUpdate < 30) return RANKING_BONUS.RECENCY_MONTH;\n if (daysSinceUpdate < 90) return RANKING_BONUS.RECENCY_QUARTER;\n return 0;\n}\n\n/**\n * Re-rank results using multiple signals: title match quality, type importance, recency.\n * Adds fixed bonuses to ensure important items rank appropriately.\n * \n * @param results - Search results with score and item (task or resource)\n * @param query - Original search query\n * @returns Re-ranked results sorted by adjusted score\n */\nfunction rerankWithSignals<T extends { score: number; item: { title?: string; type?: string; updated_at?: string } }>(\n results: T[],\n query: string\n): T[] {\n if (!query.trim()) return results;\n \n const queryLower = query.toLowerCase().trim();\n const queryWords = queryLower.split(/\\s+/);\n \n return results.map(r => {\n let bonus = 0;\n \n // Title match bonus - check in order of strength\n const title = r.item.title?.toLowerCase() || '';\n const titleWords = title.split(/\\W+/).filter(Boolean);\n \n // Count how many query words match in title\n const matchingQueryWords = queryWords.filter(qw => titleWords.includes(qw));\n const matchCount = matchingQueryWords.length;\n \n // Strongest: title starts with query (e.g., \"Backlog MCP\" for query \"backlog\")\n const titleStartsWithQuery = queryWords.some(qw => title.startsWith(qw));\n \n // Strong: query word appears as standalone word in title\n const hasExactMatch = matchCount > 0;\n \n // Weak: query appears as substring in title (compound word)\n const hasPartialMatch = !hasExactMatch && queryWords.some(qw => title.includes(qw));\n \n // Track if we have any title match for epic bonus\n const hasTitleMatch = titleStartsWithQuery || hasExactMatch || hasPartialMatch;\n \n if (titleStartsWithQuery) {\n bonus += RANKING_BONUS.TITLE_STARTS_WITH;\n } else if (hasExactMatch) {\n bonus += RANKING_BONUS.TITLE_EXACT_WORD;\n } else if (hasPartialMatch) {\n bonus += RANKING_BONUS.TITLE_PARTIAL;\n }\n \n // Multi-word match bonus: reward matching more query words in title\n // Only count additional words beyond the first match\n if (matchCount > 1) {\n bonus += (matchCount - 1) * RANKING_BONUS.MULTI_WORD_MATCH;\n }\n \n // Type importance bonus - only for epics WITH title match\n // This prevents epics from ranking above tasks when they only match in description\n if (r.item.type === 'epic' && hasTitleMatch) {\n bonus += RANKING_BONUS.EPIC_WITH_TITLE_MATCH;\n }\n \n // Recency bonus\n bonus += getRecencyBonus(r.item.updated_at);\n \n return { ...r, score: r.score + bonus };\n }).sort((a, b) => b.score - a.score);\n}\n\n/**\n * Orama-backed search service with optional hybrid search (BM25 + vector).\n * Gracefully falls back to BM25-only if embeddings fail to load.\n */\nexport class OramaSearchService implements SearchService {\n private db: OramaInstance | OramaInstanceWithEmbeddings | null = null;\n private taskCache = new Map<string, Task>();\n private resourceCache = new Map<string, Resource>();\n private saveTimeout: ReturnType<typeof setTimeout> | null = null;\n private readonly cachePath: string;\n\n // Embedding state\n private readonly hybridEnabled: boolean;\n private embedder: EmbeddingService | null = null;\n private embeddingsReady = false;\n private embeddingsInitPromise: Promise<boolean> | null = null;\n private hasEmbeddingsInIndex = false;\n\n constructor(options: OramaSearchOptions) {\n this.cachePath = options.cachePath;\n this.hybridEnabled = options.hybridSearch ?? true;\n }\n\n private get indexPath(): string {\n return this.cachePath;\n }\n\n /**\n * Lazy-load embedding service. Returns true if embeddings are available.\n */\n private async ensureEmbeddings(): Promise<boolean> {\n if (!this.hybridEnabled) return false;\n if (this.embeddingsReady) return true;\n if (this.embeddingsInitPromise) return this.embeddingsInitPromise;\n\n this.embeddingsInitPromise = (async () => {\n try {\n this.embedder = new EmbeddingService();\n await this.embedder.init();\n this.embeddingsReady = true;\n return true;\n } catch (e) {\n // Graceful fallback - embeddings unavailable, use BM25 only\n this.embedder = null;\n this.embeddingsReady = false;\n return false;\n }\n })();\n\n return this.embeddingsInitPromise;\n }\n\n private getTextForEmbedding(task: Task): string {\n return `${task.title} ${task.description || ''}`.trim();\n }\n\n private taskToDoc(task: Task): OramaDoc {\n return {\n id: task.id,\n title: task.title,\n description: task.description || '',\n status: task.status,\n type: task.type || 'task',\n epic_id: task.epic_id || '',\n evidence: (task.evidence || []).join(' '),\n blocked_reason: (task.blocked_reason || []).join(' '),\n references: (task.references || []).map(r => `${r.title || ''} ${r.url}`).join(' '),\n path: '', // Tasks don't have paths\n };\n }\n\n private resourceToDoc(resource: Resource): OramaDoc {\n return {\n id: resource.id,\n title: resource.title,\n description: resource.content, // Full content for search\n status: '',\n type: 'resource',\n epic_id: '',\n evidence: '',\n blocked_reason: '',\n references: '',\n path: resource.path,\n };\n }\n\n private getResourceTextForEmbedding(resource: Resource): string {\n return `${resource.title} ${resource.content}`.trim();\n }\n\n private async resourceToDocWithEmbeddings(resource: Resource): Promise<OramaDocWithEmbeddings> {\n const doc = this.resourceToDoc(resource);\n const text = this.getResourceTextForEmbedding(resource);\n const embeddings = await this.embedder!.embed(text);\n return { ...doc, embeddings };\n }\n\n private async taskToDocWithEmbeddings(task: Task): Promise<OramaDocWithEmbeddings> {\n const doc = this.taskToDoc(task);\n const text = this.getTextForEmbedding(task);\n const embeddings = await this.embedder!.embed(text);\n return { ...doc, embeddings };\n }\n\n private scheduleSave(): void {\n if (this.saveTimeout) clearTimeout(this.saveTimeout);\n this.saveTimeout = setTimeout(() => this.persistToDisk(), 1000);\n }\n\n private persistToDisk(): void {\n if (!this.db) return;\n try {\n const dir = dirname(this.indexPath);\n if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n const data = save(this.db);\n const serialized = JSON.stringify({\n index: data,\n tasks: Object.fromEntries(this.taskCache),\n resources: Object.fromEntries(this.resourceCache),\n hasEmbeddings: this.hasEmbeddingsInIndex,\n });\n writeFileSync(this.indexPath, serialized);\n } catch {\n // Ignore persistence errors - index will rebuild on next start\n }\n }\n\n private async loadFromDisk(): Promise<boolean> {\n try {\n if (!existsSync(this.indexPath)) return false;\n const raw = JSON.parse(readFileSync(this.indexPath, 'utf-8'));\n\n // Check if cached index has embeddings\n this.hasEmbeddingsInIndex = raw.hasEmbeddings ?? false;\n\n const schemaToUse = this.hasEmbeddingsInIndex ? schemaWithEmbeddings : schema;\n this.db = await create({ schema: schemaToUse, components: { tokenizer: hyphenAwareTokenizer } });\n load(this.db, raw.index);\n this.taskCache = new Map(Object.entries(raw.tasks as Record<string, Task>));\n this.resourceCache = new Map(Object.entries((raw.resources || {}) as Record<string, Resource>));\n return true;\n } catch {\n return false;\n }\n }\n\n async index(tasks: Task[]): Promise<void> {\n // Try loading from disk first\n if (await this.loadFromDisk()) return;\n\n // Check if embeddings are available for fresh index\n const useEmbeddings = await this.ensureEmbeddings();\n\n // Build fresh index\n const schemaToUse = useEmbeddings ? schemaWithEmbeddings : schema;\n this.db = await create({ schema: schemaToUse, components: { tokenizer: hyphenAwareTokenizer } });\n this.taskCache.clear();\n this.hasEmbeddingsInIndex = useEmbeddings;\n\n for (const task of tasks) {\n this.taskCache.set(task.id, task);\n if (useEmbeddings) {\n const doc = await this.taskToDocWithEmbeddings(task);\n await insert(this.db as OramaInstanceWithEmbeddings, doc);\n } else {\n await insert(this.db as OramaInstance, this.taskToDoc(task));\n }\n }\n this.persistToDisk();\n }\n\n async search(query: string, options?: SearchOptions): Promise<SearchResult[]> {\n if (!this.db) return [];\n if (!query.trim()) return [];\n\n const limit = options?.limit ?? 20;\n const boost = options?.boost ?? { id: 10, title: 2 };\n\n // Determine if we can use hybrid search\n const canUseHybrid = this.hasEmbeddingsInIndex && (await this.ensureEmbeddings());\n\n let results: Results<OramaDoc | OramaDocWithEmbeddings>;\n\n if (canUseHybrid) {\n // Hybrid search: BM25 + vector\n const queryVector = await this.embedder!.embed(query);\n results = await search(this.db as OramaInstanceWithEmbeddings, {\n term: query,\n mode: 'hybrid',\n vector: {\n value: queryVector,\n property: 'embeddings',\n },\n // Prioritize BM25 (exact/fuzzy matches) over vector (semantic)\n // This ensures exact matches rank highest while semantic matches are still found\n hybridWeights: { text: 0.8, vector: 0.2 },\n similarity: 0.2, // Low threshold to catch semantic matches\n limit,\n boost,\n tolerance: 1,\n });\n } else {\n // BM25 only\n results = await search(this.db, {\n term: query,\n limit,\n boost,\n tolerance: 1,\n });\n }\n\n let hits = results.hits.map(hit => ({\n id: hit.document.id,\n score: hit.score,\n task: this.taskCache.get(hit.document.id)!,\n }));\n\n // Apply filters post-search\n const filters = options?.filters;\n if (filters) {\n if (filters.status?.length) {\n hits = hits.filter(h => filters.status!.includes(h.task.status));\n }\n if (filters.type) {\n hits = hits.filter(h => (h.task.type || 'task') === filters.type);\n }\n if (filters.epic_id) {\n hits = hits.filter(h => (h.task.parent_id ?? h.task.epic_id) === filters.epic_id);\n }\n if (filters.parent_id) {\n hits = hits.filter(h => (h.task.parent_id ?? h.task.epic_id) === filters.parent_id);\n }\n }\n\n // Re-rank with multiple signals: title match, type importance, recency (ADR-0051)\n const reranked = rerankWithSignals(\n hits.map(h => ({ score: h.score, item: h.task })),\n query\n );\n hits = reranked.map((r, i) => ({\n id: hits.find(h => h.task === r.item)!.id,\n score: r.score,\n task: r.item,\n }));\n\n return hits.slice(0, limit);\n }\n\n async addDocument(task: Task): Promise<void> {\n if (!this.db) return;\n this.taskCache.set(task.id, task);\n\n try {\n if (this.hasEmbeddingsInIndex && this.embeddingsReady) {\n const doc = await this.taskToDocWithEmbeddings(task);\n await insert(this.db as OramaInstanceWithEmbeddings, doc);\n } else {\n await insert(this.db as OramaInstance, this.taskToDoc(task));\n }\n } catch (e: any) {\n if (e?.code === 'DOCUMENT_ALREADY_EXISTS') {\n await this.updateDocument(task);\n return;\n }\n throw e;\n }\n this.scheduleSave();\n }\n\n async removeDocument(id: string): Promise<void> {\n if (!this.db) return;\n this.taskCache.delete(id);\n try {\n await remove(this.db, id);\n this.scheduleSave();\n } catch {\n // Ignore if document doesn't exist\n }\n }\n\n async updateDocument(task: Task): Promise<void> {\n await this.removeDocument(task.id);\n this.taskCache.set(task.id, task);\n\n if (this.hasEmbeddingsInIndex && this.embeddingsReady) {\n const doc = await this.taskToDocWithEmbeddings(task);\n await insert(this.db as OramaInstanceWithEmbeddings, doc);\n } else {\n await insert(this.db as OramaInstance, this.taskToDoc(task));\n }\n this.scheduleSave();\n }\n\n /**\n * Check if hybrid search is currently active.\n */\n isHybridSearchActive(): boolean {\n return this.hasEmbeddingsInIndex && this.embeddingsReady;\n }\n\n /**\n * Index resources into the search index.\n * Should be called after index() to add resources to existing index.\n */\n async indexResources(resources: Resource[]): Promise<void> {\n if (!this.db) return;\n\n for (const resource of resources) {\n this.resourceCache.set(resource.id, resource);\n try {\n if (this.hasEmbeddingsInIndex && this.embeddingsReady) {\n const doc = await this.resourceToDocWithEmbeddings(resource);\n await insert(this.db as OramaInstanceWithEmbeddings, doc);\n } else {\n await insert(this.db as OramaInstance, this.resourceToDoc(resource));\n }\n } catch (e: any) {\n if (e?.code === 'DOCUMENT_ALREADY_EXISTS') {\n // Update existing resource\n await this.updateResource(resource);\n }\n // Ignore other errors - continue indexing\n }\n }\n this.scheduleSave();\n }\n\n /**\n * Search for resources only.\n */\n async searchResources(query: string, options?: { limit?: number }): Promise<ResourceSearchResult[]> {\n if (!this.db) return [];\n if (!query.trim()) return [];\n\n const limit = options?.limit ?? 20;\n const boost = { title: 2, description: 1 };\n\n const canUseHybrid = this.hasEmbeddingsInIndex && (await this.ensureEmbeddings());\n\n let results: Results<OramaDoc | OramaDocWithEmbeddings>;\n\n if (canUseHybrid) {\n const queryVector = await this.embedder!.embed(query);\n results = await search(this.db as OramaInstanceWithEmbeddings, {\n term: query,\n mode: 'hybrid',\n vector: { value: queryVector, property: 'embeddings' },\n hybridWeights: { text: 0.8, vector: 0.2 },\n similarity: 0.2,\n limit: limit * 3, // Fetch more to filter\n boost,\n tolerance: 1,\n });\n } else {\n results = await search(this.db, {\n term: query,\n limit: limit * 3,\n boost,\n tolerance: 1,\n });\n }\n\n // Filter to resources only\n let resourceHits = results.hits\n .filter(hit => hit.document.type === 'resource')\n .map(hit => ({\n id: hit.document.id,\n score: hit.score,\n resource: this.resourceCache.get(hit.document.id)!,\n }))\n .filter(h => h.resource);\n\n // Re-rank with multiple signals (ADR-0051) - resources don't have type/recency, just title\n const reranked = rerankWithSignals(\n resourceHits.map(h => ({ score: h.score, item: h.resource })),\n query\n );\n resourceHits = reranked.map(r => ({\n id: (r.item as Resource).id,\n score: r.score,\n resource: r.item as Resource,\n }));\n\n return resourceHits.slice(0, limit);\n }\n\n /**\n * Search all document types with optional type filtering.\n * Returns results sorted by relevance across all types.\n */\n async searchAll(query: string, options?: SearchOptions): Promise<Array<{ id: string; score: number; type: SearchableType; item: Task | Resource }>> {\n if (!this.db) return [];\n if (!query.trim()) return [];\n\n const limit = options?.limit ?? 20;\n const docTypes = options?.docTypes;\n const boost = options?.boost ?? { id: 10, title: 2 };\n const sortMode = options?.sort ?? 'relevant';\n\n const canUseHybrid = this.hasEmbeddingsInIndex && (await this.ensureEmbeddings());\n\n let results: Results<OramaDoc | OramaDocWithEmbeddings>;\n\n if (canUseHybrid) {\n const queryVector = await this.embedder!.embed(query);\n results = await search(this.db as OramaInstanceWithEmbeddings, {\n term: query,\n mode: 'hybrid',\n vector: { value: queryVector, property: 'embeddings' },\n hybridWeights: { text: 0.8, vector: 0.2 },\n similarity: 0.2,\n limit: limit * 3,\n boost,\n tolerance: 1,\n });\n } else {\n results = await search(this.db, {\n term: query,\n limit: limit * 3,\n boost,\n tolerance: 1,\n });\n }\n\n let hits = results.hits.map(hit => {\n const docType = hit.document.type as SearchableType;\n const isResource = docType === 'resource';\n return {\n id: hit.document.id,\n score: hit.score,\n type: docType,\n item: isResource \n ? this.resourceCache.get(hit.document.id)! \n : this.taskCache.get(hit.document.id)!,\n };\n }).filter(h => h.item);\n\n // Filter by document types if specified\n if (docTypes?.length) {\n hits = hits.filter(h => docTypes.includes(h.type));\n }\n\n // Apply task-specific filters\n const filters = options?.filters;\n if (filters) {\n hits = hits.filter(h => {\n if (h.type === 'resource') return true; // Resources don't have these filters\n const task = h.item as Task;\n if (filters.status?.length && !filters.status.includes(task.status)) return false;\n if (filters.type && (task.type || 'task') !== filters.type) return false;\n if (filters.epic_id && (task.parent_id ?? task.epic_id) !== filters.epic_id) return false;\n if (filters.parent_id && (task.parent_id ?? task.epic_id) !== filters.parent_id) return false;\n return true;\n });\n }\n\n // Sort based on mode\n if (sortMode === 'recent') {\n // Sort by updated_at descending (most recent first)\n hits.sort((a, b) => {\n const aDate = (a.item as Task).updated_at || '';\n const bDate = (b.item as Task).updated_at || '';\n return bDate.localeCompare(aDate);\n });\n } else {\n // Re-rank with multiple signals: title match, type importance, recency (ADR-0051)\n hits = rerankWithSignals(hits, query);\n }\n\n return hits.slice(0, limit);\n }\n\n async addResource(resource: Resource): Promise<void> {\n if (!this.db) return;\n this.resourceCache.set(resource.id, resource);\n\n try {\n if (this.hasEmbeddingsInIndex && this.embeddingsReady) {\n const doc = await this.resourceToDocWithEmbeddings(resource);\n await insert(this.db as OramaInstanceWithEmbeddings, doc);\n } else {\n await insert(this.db as OramaInstance, this.resourceToDoc(resource));\n }\n } catch (e: any) {\n if (e?.code === 'DOCUMENT_ALREADY_EXISTS') {\n await this.updateResource(resource);\n return;\n }\n throw e;\n }\n this.scheduleSave();\n }\n\n async removeResource(id: string): Promise<void> {\n if (!this.db) return;\n this.resourceCache.delete(id);\n try {\n await remove(this.db, id);\n this.scheduleSave();\n } catch {\n // Ignore if document doesn't exist\n }\n }\n\n async updateResource(resource: Resource): Promise<void> {\n await this.removeResource(resource.id);\n this.resourceCache.set(resource.id, resource);\n\n if (this.hasEmbeddingsInIndex && this.embeddingsReady) {\n const doc = await this.resourceToDocWithEmbeddings(resource);\n await insert(this.db as OramaInstanceWithEmbeddings, doc);\n } else {\n await insert(this.db as OramaInstance, this.resourceToDoc(resource));\n }\n this.scheduleSave();\n }\n}\n"],"mappings":";;;;;;AAwBA,MAAM,SAAS;CACb,IAAI;CACJ,OAAO;CACP,aAAa;CACb,QAAQ;CACR,MAAM;CACN,SAAS;CACT,UAAU;CACV,gBAAgB;CAChB,YAAY;CACZ,MAAM;CACP;AAED,MAAM,uBAAuB;CAC3B,GAAG;CACH,YAAY,UAAU,qBAAqB;CAC5C;;;;AAcD,MAAM,uBAAkC;CACtC,UAAU;CACV,oCAAoB,IAAI,KAAK;CAC7B,SAAS,OAAyB;AAChC,MAAI,OAAO,UAAU,SAAU,QAAO,EAAE;EACxC,MAAM,SAAS,MAAM,aAAa,CAAC,MAAM,iBAAiB,CAAC,OAAO,QAAQ;EAC1E,MAAM,WAAqB,EAAE;AAC7B,OAAK,MAAM,SAAS,QAAQ;AAC1B,YAAS,KAAK,MAAM;AACpB,OAAI,MAAM,SAAS,IAAI,CACrB,UAAS,KAAK,GAAG,MAAM,MAAM,KAAK,CAAC,OAAO,QAAQ,CAAC;;AAGvD,SAAO,CAAC,GAAG,IAAI,IAAI,SAAS,CAAC;;CAEhC;;;;;;;;;;;;AAaD,MAAM,gBAAgB;CAEpB,mBAAmB;CACnB,kBAAkB;CAClB,eAAe;CAEf,kBAAkB;CAElB,uBAAuB;CAEvB,eAAe;CACf,cAAc;CACd,eAAe;CACf,iBAAiB;CAClB;;;;AAKD,SAAS,gBAAgB,WAAuC;AAC9D,KAAI,CAAC,UAAW,QAAO;CACvB,MAAM,mBAAmB,KAAK,KAAK,GAAG,IAAI,KAAK,UAAU,CAAC,SAAS,KAAK,MAAO,KAAK,KAAK;AACzF,KAAI,kBAAkB,EAAG,QAAO,cAAc;AAC9C,KAAI,kBAAkB,EAAG,QAAO,cAAc;AAC9C,KAAI,kBAAkB,GAAI,QAAO,cAAc;AAC/C,KAAI,kBAAkB,GAAI,QAAO,cAAc;AAC/C,QAAO;;;;;;;;;;AAWT,SAAS,kBACP,SACA,OACK;AACL,KAAI,CAAC,MAAM,MAAM,CAAE,QAAO;CAG1B,MAAM,aADa,MAAM,aAAa,CAAC,MAAM,CACf,MAAM,MAAM;AAE1C,QAAO,QAAQ,KAAI,MAAK;EACtB,IAAI,QAAQ;EAGZ,MAAM,QAAQ,EAAE,KAAK,OAAO,aAAa,IAAI;EAC7C,MAAM,aAAa,MAAM,MAAM,MAAM,CAAC,OAAO,QAAQ;EAIrD,MAAM,aADqB,WAAW,QAAO,OAAM,WAAW,SAAS,GAAG,CAAC,CACrC;EAGtC,MAAM,uBAAuB,WAAW,MAAK,OAAM,MAAM,WAAW,GAAG,CAAC;EAGxE,MAAM,gBAAgB,aAAa;EAGnC,MAAM,kBAAkB,CAAC,iBAAiB,WAAW,MAAK,OAAM,MAAM,SAAS,GAAG,CAAC;EAGnF,MAAM,gBAAgB,wBAAwB,iBAAiB;AAE/D,MAAI,qBACF,UAAS,cAAc;WACd,cACT,UAAS,cAAc;WACd,gBACT,UAAS,cAAc;AAKzB,MAAI,aAAa,EACf,WAAU,aAAa,KAAK,cAAc;AAK5C,MAAI,EAAE,KAAK,SAAS,UAAU,cAC5B,UAAS,cAAc;AAIzB,WAAS,gBAAgB,EAAE,KAAK,WAAW;AAE3C,SAAO;GAAE,GAAG;GAAG,OAAO,EAAE,QAAQ;GAAO;GACvC,CAAC,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,MAAM;;;;;;AAOtC,IAAa,qBAAb,MAAyD;CACvD,AAAQ,KAAyD;CACjE,AAAQ,4BAAY,IAAI,KAAmB;CAC3C,AAAQ,gCAAgB,IAAI,KAAuB;CACnD,AAAQ,cAAoD;CAC5D,AAAiB;CAGjB,AAAiB;CACjB,AAAQ,WAAoC;CAC5C,AAAQ,kBAAkB;CAC1B,AAAQ,wBAAiD;CACzD,AAAQ,uBAAuB;CAE/B,YAAY,SAA6B;AACvC,OAAK,YAAY,QAAQ;AACzB,OAAK,gBAAgB,QAAQ,gBAAgB;;CAG/C,IAAY,YAAoB;AAC9B,SAAO,KAAK;;;;;CAMd,MAAc,mBAAqC;AACjD,MAAI,CAAC,KAAK,cAAe,QAAO;AAChC,MAAI,KAAK,gBAAiB,QAAO;AACjC,MAAI,KAAK,sBAAuB,QAAO,KAAK;AAE5C,OAAK,yBAAyB,YAAY;AACxC,OAAI;AACF,SAAK,WAAW,IAAI,kBAAkB;AACtC,UAAM,KAAK,SAAS,MAAM;AAC1B,SAAK,kBAAkB;AACvB,WAAO;YACA,GAAG;AAEV,SAAK,WAAW;AAChB,SAAK,kBAAkB;AACvB,WAAO;;MAEP;AAEJ,SAAO,KAAK;;CAGd,AAAQ,oBAAoB,MAAoB;AAC9C,SAAO,GAAG,KAAK,MAAM,GAAG,KAAK,eAAe,KAAK,MAAM;;CAGzD,AAAQ,UAAU,MAAsB;AACtC,SAAO;GACL,IAAI,KAAK;GACT,OAAO,KAAK;GACZ,aAAa,KAAK,eAAe;GACjC,QAAQ,KAAK;GACb,MAAM,KAAK,QAAQ;GACnB,SAAS,KAAK,WAAW;GACzB,WAAW,KAAK,YAAY,EAAE,EAAE,KAAK,IAAI;GACzC,iBAAiB,KAAK,kBAAkB,EAAE,EAAE,KAAK,IAAI;GACrD,aAAa,KAAK,cAAc,EAAE,EAAE,KAAI,MAAK,GAAG,EAAE,SAAS,GAAG,GAAG,EAAE,MAAM,CAAC,KAAK,IAAI;GACnF,MAAM;GACP;;CAGH,AAAQ,cAAc,UAA8B;AAClD,SAAO;GACL,IAAI,SAAS;GACb,OAAO,SAAS;GAChB,aAAa,SAAS;GACtB,QAAQ;GACR,MAAM;GACN,SAAS;GACT,UAAU;GACV,gBAAgB;GAChB,YAAY;GACZ,MAAM,SAAS;GAChB;;CAGH,AAAQ,4BAA4B,UAA4B;AAC9D,SAAO,GAAG,SAAS,MAAM,GAAG,SAAS,UAAU,MAAM;;CAGvD,MAAc,4BAA4B,UAAqD;EAC7F,MAAM,MAAM,KAAK,cAAc,SAAS;EACxC,MAAM,OAAO,KAAK,4BAA4B,SAAS;EACvD,MAAM,aAAa,MAAM,KAAK,SAAU,MAAM,KAAK;AACnD,SAAO;GAAE,GAAG;GAAK;GAAY;;CAG/B,MAAc,wBAAwB,MAA6C;EACjF,MAAM,MAAM,KAAK,UAAU,KAAK;EAChC,MAAM,OAAO,KAAK,oBAAoB,KAAK;EAC3C,MAAM,aAAa,MAAM,KAAK,SAAU,MAAM,KAAK;AACnD,SAAO;GAAE,GAAG;GAAK;GAAY;;CAG/B,AAAQ,eAAqB;AAC3B,MAAI,KAAK,YAAa,cAAa,KAAK,YAAY;AACpD,OAAK,cAAc,iBAAiB,KAAK,eAAe,EAAE,IAAK;;CAGjE,AAAQ,gBAAsB;AAC5B,MAAI,CAAC,KAAK,GAAI;AACd,MAAI;GACF,MAAM,MAAM,QAAQ,KAAK,UAAU;AACnC,OAAI,CAAC,WAAW,IAAI,CAAE,WAAU,KAAK,EAAE,WAAW,MAAM,CAAC;GACzD,MAAM,OAAO,KAAK,KAAK,GAAG;GAC1B,MAAM,aAAa,KAAK,UAAU;IAChC,OAAO;IACP,OAAO,OAAO,YAAY,KAAK,UAAU;IACzC,WAAW,OAAO,YAAY,KAAK,cAAc;IACjD,eAAe,KAAK;IACrB,CAAC;AACF,iBAAc,KAAK,WAAW,WAAW;UACnC;;CAKV,MAAc,eAAiC;AAC7C,MAAI;AACF,OAAI,CAAC,WAAW,KAAK,UAAU,CAAE,QAAO;GACxC,MAAM,MAAM,KAAK,MAAM,aAAa,KAAK,WAAW,QAAQ,CAAC;AAG7D,QAAK,uBAAuB,IAAI,iBAAiB;AAGjD,QAAK,KAAK,MAAM,OAAO;IAAE,QADL,KAAK,uBAAuB,uBAAuB;IACzB,YAAY,EAAE,WAAW,sBAAsB;IAAE,CAAC;AAChG,QAAK,KAAK,IAAI,IAAI,MAAM;AACxB,QAAK,YAAY,IAAI,IAAI,OAAO,QAAQ,IAAI,MAA8B,CAAC;AAC3E,QAAK,gBAAgB,IAAI,IAAI,OAAO,QAAS,IAAI,aAAa,EAAE,CAA8B,CAAC;AAC/F,UAAO;UACD;AACN,UAAO;;;CAIX,MAAM,MAAM,OAA8B;AAExC,MAAI,MAAM,KAAK,cAAc,CAAE;EAG/B,MAAM,gBAAgB,MAAM,KAAK,kBAAkB;AAInD,OAAK,KAAK,MAAM,OAAO;GAAE,QADL,gBAAgB,uBAAuB;GACb,YAAY,EAAE,WAAW,sBAAsB;GAAE,CAAC;AAChG,OAAK,UAAU,OAAO;AACtB,OAAK,uBAAuB;AAE5B,OAAK,MAAM,QAAQ,OAAO;AACxB,QAAK,UAAU,IAAI,KAAK,IAAI,KAAK;AACjC,OAAI,eAAe;IACjB,MAAM,MAAM,MAAM,KAAK,wBAAwB,KAAK;AACpD,UAAM,OAAO,KAAK,IAAmC,IAAI;SAEzD,OAAM,OAAO,KAAK,IAAqB,KAAK,UAAU,KAAK,CAAC;;AAGhE,OAAK,eAAe;;CAGtB,MAAM,OAAO,OAAe,SAAkD;AAC5E,MAAI,CAAC,KAAK,GAAI,QAAO,EAAE;AACvB,MAAI,CAAC,MAAM,MAAM,CAAE,QAAO,EAAE;EAE5B,MAAM,QAAQ,SAAS,SAAS;EAChC,MAAM,QAAQ,SAAS,SAAS;GAAE,IAAI;GAAI,OAAO;GAAG;EAGpD,MAAM,eAAe,KAAK,wBAAyB,MAAM,KAAK,kBAAkB;EAEhF,IAAI;AAEJ,MAAI,cAAc;GAEhB,MAAM,cAAc,MAAM,KAAK,SAAU,MAAM,MAAM;AACrD,aAAU,MAAM,OAAO,KAAK,IAAmC;IAC7D,MAAM;IACN,MAAM;IACN,QAAQ;KACN,OAAO;KACP,UAAU;KACX;IAGD,eAAe;KAAE,MAAM;KAAK,QAAQ;KAAK;IACzC,YAAY;IACZ;IACA;IACA,WAAW;IACZ,CAAC;QAGF,WAAU,MAAM,OAAO,KAAK,IAAI;GAC9B,MAAM;GACN;GACA;GACA,WAAW;GACZ,CAAC;EAGJ,IAAI,OAAO,QAAQ,KAAK,KAAI,SAAQ;GAClC,IAAI,IAAI,SAAS;GACjB,OAAO,IAAI;GACX,MAAM,KAAK,UAAU,IAAI,IAAI,SAAS,GAAG;GAC1C,EAAE;EAGH,MAAM,UAAU,SAAS;AACzB,MAAI,SAAS;AACX,OAAI,QAAQ,QAAQ,OAClB,QAAO,KAAK,QAAO,MAAK,QAAQ,OAAQ,SAAS,EAAE,KAAK,OAAO,CAAC;AAElE,OAAI,QAAQ,KACV,QAAO,KAAK,QAAO,OAAM,EAAE,KAAK,QAAQ,YAAY,QAAQ,KAAK;AAEnE,OAAI,QAAQ,QACV,QAAO,KAAK,QAAO,OAAM,EAAE,KAAK,aAAa,EAAE,KAAK,aAAa,QAAQ,QAAQ;AAEnF,OAAI,QAAQ,UACV,QAAO,KAAK,QAAO,OAAM,EAAE,KAAK,aAAa,EAAE,KAAK,aAAa,QAAQ,UAAU;;AASvF,SAJiB,kBACf,KAAK,KAAI,OAAM;GAAE,OAAO,EAAE;GAAO,MAAM,EAAE;GAAM,EAAE,EACjD,MACD,CACe,KAAK,GAAG,OAAO;GAC7B,IAAI,KAAK,MAAK,MAAK,EAAE,SAAS,EAAE,KAAK,CAAE;GACvC,OAAO,EAAE;GACT,MAAM,EAAE;GACT,EAAE;AAEH,SAAO,KAAK,MAAM,GAAG,MAAM;;CAG7B,MAAM,YAAY,MAA2B;AAC3C,MAAI,CAAC,KAAK,GAAI;AACd,OAAK,UAAU,IAAI,KAAK,IAAI,KAAK;AAEjC,MAAI;AACF,OAAI,KAAK,wBAAwB,KAAK,iBAAiB;IACrD,MAAM,MAAM,MAAM,KAAK,wBAAwB,KAAK;AACpD,UAAM,OAAO,KAAK,IAAmC,IAAI;SAEzD,OAAM,OAAO,KAAK,IAAqB,KAAK,UAAU,KAAK,CAAC;WAEvD,GAAQ;AACf,OAAI,GAAG,SAAS,2BAA2B;AACzC,UAAM,KAAK,eAAe,KAAK;AAC/B;;AAEF,SAAM;;AAER,OAAK,cAAc;;CAGrB,MAAM,eAAe,IAA2B;AAC9C,MAAI,CAAC,KAAK,GAAI;AACd,OAAK,UAAU,OAAO,GAAG;AACzB,MAAI;AACF,SAAM,OAAO,KAAK,IAAI,GAAG;AACzB,QAAK,cAAc;UACb;;CAKV,MAAM,eAAe,MAA2B;AAC9C,QAAM,KAAK,eAAe,KAAK,GAAG;AAClC,OAAK,UAAU,IAAI,KAAK,IAAI,KAAK;AAEjC,MAAI,KAAK,wBAAwB,KAAK,iBAAiB;GACrD,MAAM,MAAM,MAAM,KAAK,wBAAwB,KAAK;AACpD,SAAM,OAAO,KAAK,IAAmC,IAAI;QAEzD,OAAM,OAAO,KAAK,IAAqB,KAAK,UAAU,KAAK,CAAC;AAE9D,OAAK,cAAc;;;;;CAMrB,uBAAgC;AAC9B,SAAO,KAAK,wBAAwB,KAAK;;;;;;CAO3C,MAAM,eAAe,WAAsC;AACzD,MAAI,CAAC,KAAK,GAAI;AAEd,OAAK,MAAM,YAAY,WAAW;AAChC,QAAK,cAAc,IAAI,SAAS,IAAI,SAAS;AAC7C,OAAI;AACF,QAAI,KAAK,wBAAwB,KAAK,iBAAiB;KACrD,MAAM,MAAM,MAAM,KAAK,4BAA4B,SAAS;AAC5D,WAAM,OAAO,KAAK,IAAmC,IAAI;UAEzD,OAAM,OAAO,KAAK,IAAqB,KAAK,cAAc,SAAS,CAAC;YAE/D,GAAQ;AACf,QAAI,GAAG,SAAS,0BAEd,OAAM,KAAK,eAAe,SAAS;;;AAKzC,OAAK,cAAc;;;;;CAMrB,MAAM,gBAAgB,OAAe,SAA+D;AAClG,MAAI,CAAC,KAAK,GAAI,QAAO,EAAE;AACvB,MAAI,CAAC,MAAM,MAAM,CAAE,QAAO,EAAE;EAE5B,MAAM,QAAQ,SAAS,SAAS;EAChC,MAAM,QAAQ;GAAE,OAAO;GAAG,aAAa;GAAG;EAE1C,MAAM,eAAe,KAAK,wBAAyB,MAAM,KAAK,kBAAkB;EAEhF,IAAI;AAEJ,MAAI,cAAc;GAChB,MAAM,cAAc,MAAM,KAAK,SAAU,MAAM,MAAM;AACrD,aAAU,MAAM,OAAO,KAAK,IAAmC;IAC7D,MAAM;IACN,MAAM;IACN,QAAQ;KAAE,OAAO;KAAa,UAAU;KAAc;IACtD,eAAe;KAAE,MAAM;KAAK,QAAQ;KAAK;IACzC,YAAY;IACZ,OAAO,QAAQ;IACf;IACA,WAAW;IACZ,CAAC;QAEF,WAAU,MAAM,OAAO,KAAK,IAAI;GAC9B,MAAM;GACN,OAAO,QAAQ;GACf;GACA,WAAW;GACZ,CAAC;EAIJ,IAAI,eAAe,QAAQ,KACxB,QAAO,QAAO,IAAI,SAAS,SAAS,WAAW,CAC/C,KAAI,SAAQ;GACX,IAAI,IAAI,SAAS;GACjB,OAAO,IAAI;GACX,UAAU,KAAK,cAAc,IAAI,IAAI,SAAS,GAAG;GAClD,EAAE,CACF,QAAO,MAAK,EAAE,SAAS;AAO1B,iBAJiB,kBACf,aAAa,KAAI,OAAM;GAAE,OAAO,EAAE;GAAO,MAAM,EAAE;GAAU,EAAE,EAC7D,MACD,CACuB,KAAI,OAAM;GAChC,IAAK,EAAE,KAAkB;GACzB,OAAO,EAAE;GACT,UAAU,EAAE;GACb,EAAE;AAEH,SAAO,aAAa,MAAM,GAAG,MAAM;;;;;;CAOrC,MAAM,UAAU,OAAe,SAAqH;AAClJ,MAAI,CAAC,KAAK,GAAI,QAAO,EAAE;AACvB,MAAI,CAAC,MAAM,MAAM,CAAE,QAAO,EAAE;EAE5B,MAAM,QAAQ,SAAS,SAAS;EAChC,MAAM,WAAW,SAAS;EAC1B,MAAM,QAAQ,SAAS,SAAS;GAAE,IAAI;GAAI,OAAO;GAAG;EACpD,MAAM,WAAW,SAAS,QAAQ;EAElC,MAAM,eAAe,KAAK,wBAAyB,MAAM,KAAK,kBAAkB;EAEhF,IAAI;AAEJ,MAAI,cAAc;GAChB,MAAM,cAAc,MAAM,KAAK,SAAU,MAAM,MAAM;AACrD,aAAU,MAAM,OAAO,KAAK,IAAmC;IAC7D,MAAM;IACN,MAAM;IACN,QAAQ;KAAE,OAAO;KAAa,UAAU;KAAc;IACtD,eAAe;KAAE,MAAM;KAAK,QAAQ;KAAK;IACzC,YAAY;IACZ,OAAO,QAAQ;IACf;IACA,WAAW;IACZ,CAAC;QAEF,WAAU,MAAM,OAAO,KAAK,IAAI;GAC9B,MAAM;GACN,OAAO,QAAQ;GACf;GACA,WAAW;GACZ,CAAC;EAGJ,IAAI,OAAO,QAAQ,KAAK,KAAI,QAAO;GACjC,MAAM,UAAU,IAAI,SAAS;GAC7B,MAAM,aAAa,YAAY;AAC/B,UAAO;IACL,IAAI,IAAI,SAAS;IACjB,OAAO,IAAI;IACX,MAAM;IACN,MAAM,aACF,KAAK,cAAc,IAAI,IAAI,SAAS,GAAG,GACvC,KAAK,UAAU,IAAI,IAAI,SAAS,GAAG;IACxC;IACD,CAAC,QAAO,MAAK,EAAE,KAAK;AAGtB,MAAI,UAAU,OACZ,QAAO,KAAK,QAAO,MAAK,SAAS,SAAS,EAAE,KAAK,CAAC;EAIpD,MAAM,UAAU,SAAS;AACzB,MAAI,QACF,QAAO,KAAK,QAAO,MAAK;AACtB,OAAI,EAAE,SAAS,WAAY,QAAO;GAClC,MAAM,OAAO,EAAE;AACf,OAAI,QAAQ,QAAQ,UAAU,CAAC,QAAQ,OAAO,SAAS,KAAK,OAAO,CAAE,QAAO;AAC5E,OAAI,QAAQ,SAAS,KAAK,QAAQ,YAAY,QAAQ,KAAM,QAAO;AACnE,OAAI,QAAQ,YAAY,KAAK,aAAa,KAAK,aAAa,QAAQ,QAAS,QAAO;AACpF,OAAI,QAAQ,cAAc,KAAK,aAAa,KAAK,aAAa,QAAQ,UAAW,QAAO;AACxF,UAAO;IACP;AAIJ,MAAI,aAAa,SAEf,MAAK,MAAM,GAAG,MAAM;GAClB,MAAM,QAAS,EAAE,KAAc,cAAc;AAE7C,WADe,EAAE,KAAc,cAAc,IAChC,cAAc,MAAM;IACjC;MAGF,QAAO,kBAAkB,MAAM,MAAM;AAGvC,SAAO,KAAK,MAAM,GAAG,MAAM;;CAG7B,MAAM,YAAY,UAAmC;AACnD,MAAI,CAAC,KAAK,GAAI;AACd,OAAK,cAAc,IAAI,SAAS,IAAI,SAAS;AAE7C,MAAI;AACF,OAAI,KAAK,wBAAwB,KAAK,iBAAiB;IACrD,MAAM,MAAM,MAAM,KAAK,4BAA4B,SAAS;AAC5D,UAAM,OAAO,KAAK,IAAmC,IAAI;SAEzD,OAAM,OAAO,KAAK,IAAqB,KAAK,cAAc,SAAS,CAAC;WAE/D,GAAQ;AACf,OAAI,GAAG,SAAS,2BAA2B;AACzC,UAAM,KAAK,eAAe,SAAS;AACnC;;AAEF,SAAM;;AAER,OAAK,cAAc;;CAGrB,MAAM,eAAe,IAA2B;AAC9C,MAAI,CAAC,KAAK,GAAI;AACd,OAAK,cAAc,OAAO,GAAG;AAC7B,MAAI;AACF,SAAM,OAAO,KAAK,IAAI,GAAG;AACzB,QAAK,cAAc;UACb;;CAKV,MAAM,eAAe,UAAmC;AACtD,QAAM,KAAK,eAAe,SAAS,GAAG;AACtC,OAAK,cAAc,IAAI,SAAS,IAAI,SAAS;AAE7C,MAAI,KAAK,wBAAwB,KAAK,iBAAiB;GACrD,MAAM,MAAM,MAAM,KAAK,4BAA4B,SAAS;AAC5D,SAAM,OAAO,KAAK,IAAmC,IAAI;QAEzD,OAAM,OAAO,KAAK,IAAqB,KAAK,cAAc,SAAS,CAAC;AAEtE,OAAK,cAAc"}
|
package/dist/search/types.d.mts
CHANGED
|
@@ -2,6 +2,7 @@ import { paths } from "../utils/paths.mjs";
|
|
|
2
2
|
import { resourceManager } from "../resources/manager.mjs";
|
|
3
3
|
import { storage } from "../storage/backlog-service.mjs";
|
|
4
4
|
import { operationLogger } from "../operations/logger.mjs";
|
|
5
|
+
import { eventBus } from "../events/index.mjs";
|
|
5
6
|
import "../operations/index.mjs";
|
|
6
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
7
8
|
import matter from "gray-matter";
|
|
@@ -9,6 +10,7 @@ import fastifyStatic from "@fastify/static";
|
|
|
9
10
|
import { exec } from "node:child_process";
|
|
10
11
|
|
|
11
12
|
//#region src/server/viewer-routes.ts
|
|
13
|
+
const SSE_HEARTBEAT_MS = 3e4;
|
|
12
14
|
function registerViewerRoutes(app) {
|
|
13
15
|
app.register(fastifyStatic, {
|
|
14
16
|
root: paths.viewerDist,
|
|
@@ -48,9 +50,13 @@ function registerViewerRoutes(app) {
|
|
|
48
50
|
const task = storage.get(id);
|
|
49
51
|
if (!task) return reply.code(404).send({ error: "Task not found" });
|
|
50
52
|
const raw = storage.getMarkdown(id);
|
|
53
|
+
const parentId = task.parent_id ?? task.epic_id;
|
|
54
|
+
let parentTitle;
|
|
55
|
+
if (parentId) parentTitle = storage.get(parentId)?.title;
|
|
51
56
|
return {
|
|
52
57
|
...task,
|
|
53
|
-
raw
|
|
58
|
+
raw,
|
|
59
|
+
parentTitle
|
|
54
60
|
};
|
|
55
61
|
});
|
|
56
62
|
app.get("/api/status", async () => {
|
|
@@ -156,7 +162,7 @@ function registerViewerRoutes(app) {
|
|
|
156
162
|
const taskData = storage.get(op.resourceId);
|
|
157
163
|
taskCache.set(op.resourceId, {
|
|
158
164
|
title: taskData?.title,
|
|
159
|
-
epicId: taskData?.epic_id
|
|
165
|
+
epicId: taskData?.parent_id ?? taskData?.epic_id
|
|
160
166
|
});
|
|
161
167
|
}
|
|
162
168
|
const cached = taskCache.get(op.resourceId);
|
|
@@ -182,6 +188,36 @@ function registerViewerRoutes(app) {
|
|
|
182
188
|
const { taskId } = request.params;
|
|
183
189
|
return { count: operationLogger.countForTask(taskId) };
|
|
184
190
|
});
|
|
191
|
+
app.get("/events", (request, reply) => {
|
|
192
|
+
const lastEventId = request.headers["last-event-id"];
|
|
193
|
+
reply.hijack();
|
|
194
|
+
const raw = reply.raw;
|
|
195
|
+
raw.writeHead(200, {
|
|
196
|
+
"Content-Type": "text/event-stream",
|
|
197
|
+
"Cache-Control": "no-cache",
|
|
198
|
+
"Connection": "keep-alive",
|
|
199
|
+
"X-Accel-Buffering": "no"
|
|
200
|
+
});
|
|
201
|
+
if (lastEventId) {
|
|
202
|
+
const seq = parseInt(lastEventId, 10);
|
|
203
|
+
if (!isNaN(seq)) {
|
|
204
|
+
const missed = eventBus.replaySince(seq);
|
|
205
|
+
for (const event of missed) raw.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
raw.write(`: connected\n\n`);
|
|
209
|
+
const onEvent = (event) => {
|
|
210
|
+
raw.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
211
|
+
};
|
|
212
|
+
eventBus.subscribe(onEvent);
|
|
213
|
+
const heartbeat = setInterval(() => {
|
|
214
|
+
raw.write(`: heartbeat\n\n`);
|
|
215
|
+
}, SSE_HEARTBEAT_MS);
|
|
216
|
+
request.raw.on("close", () => {
|
|
217
|
+
clearInterval(heartbeat);
|
|
218
|
+
eventBus.unsubscribe(onEvent);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
185
221
|
}
|
|
186
222
|
|
|
187
223
|
//#endregion
|