memory-journal-mcp 4.4.2 → 5.0.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/.github/workflows/codeql.yml +1 -6
- package/.github/workflows/docker-publish.yml +15 -49
- package/.github/workflows/lint-and-test.yml +1 -1
- package/.github/workflows/secrets-scanning.yml +4 -3
- package/.github/workflows/security-update.yml +3 -3
- package/CHANGELOG.md +213 -0
- package/CONTRIBUTING.md +132 -97
- package/DOCKER_README.md +184 -235
- package/Dockerfile +27 -24
- package/README.md +218 -190
- package/SECURITY.md +27 -35
- package/dist/cli.js +16 -1
- package/dist/cli.js.map +1 -1
- package/dist/constants/ServerInstructions.d.ts +5 -1
- package/dist/constants/ServerInstructions.d.ts.map +1 -1
- package/dist/constants/ServerInstructions.js +133 -73
- package/dist/constants/ServerInstructions.js.map +1 -1
- package/dist/constants/icons.d.ts +2 -2
- package/dist/constants/icons.d.ts.map +1 -1
- package/dist/constants/icons.js +7 -6
- package/dist/constants/icons.js.map +1 -1
- package/dist/database/SqliteAdapter.d.ts +37 -24
- package/dist/database/SqliteAdapter.d.ts.map +1 -1
- package/dist/database/SqliteAdapter.js +319 -157
- package/dist/database/SqliteAdapter.js.map +1 -1
- package/dist/database/schema.d.ts +45 -0
- package/dist/database/schema.d.ts.map +1 -0
- package/dist/database/schema.js +92 -0
- package/dist/database/schema.js.map +1 -0
- package/dist/filtering/ToolFilter.d.ts +1 -1
- package/dist/filtering/ToolFilter.d.ts.map +1 -1
- package/dist/filtering/ToolFilter.js +13 -2
- package/dist/filtering/ToolFilter.js.map +1 -1
- package/dist/github/GitHubIntegration.d.ts.map +1 -1
- package/dist/github/GitHubIntegration.js +1 -3
- package/dist/github/GitHubIntegration.js.map +1 -1
- package/dist/handlers/prompts/github.d.ts +12 -0
- package/dist/handlers/prompts/github.d.ts.map +1 -0
- package/dist/handlers/prompts/github.js +178 -0
- package/dist/handlers/prompts/github.js.map +1 -0
- package/dist/handlers/prompts/index.d.ts +23 -2
- package/dist/handlers/prompts/index.d.ts.map +1 -1
- package/dist/handlers/prompts/index.js +7 -432
- package/dist/handlers/prompts/index.js.map +1 -1
- package/dist/handlers/prompts/workflow.d.ts +12 -0
- package/dist/handlers/prompts/workflow.d.ts.map +1 -0
- package/dist/handlers/prompts/workflow.js +277 -0
- package/dist/handlers/prompts/workflow.js.map +1 -0
- package/dist/handlers/resources/core.d.ts +11 -0
- package/dist/handlers/resources/core.d.ts.map +1 -0
- package/dist/handlers/resources/core.js +433 -0
- package/dist/handlers/resources/core.js.map +1 -0
- package/dist/handlers/resources/github.d.ts +11 -0
- package/dist/handlers/resources/github.d.ts.map +1 -0
- package/dist/handlers/resources/github.js +314 -0
- package/dist/handlers/resources/github.js.map +1 -0
- package/dist/handlers/resources/graph.d.ts +11 -0
- package/dist/handlers/resources/graph.d.ts.map +1 -0
- package/dist/handlers/resources/graph.js +204 -0
- package/dist/handlers/resources/graph.js.map +1 -0
- package/dist/handlers/resources/index.d.ts +5 -20
- package/dist/handlers/resources/index.d.ts.map +1 -1
- package/dist/handlers/resources/index.js +16 -1278
- package/dist/handlers/resources/index.js.map +1 -1
- package/dist/handlers/resources/shared.d.ts +60 -0
- package/dist/handlers/resources/shared.d.ts.map +1 -0
- package/dist/handlers/resources/shared.js +49 -0
- package/dist/handlers/resources/shared.js.map +1 -0
- package/dist/handlers/resources/team.d.ts +13 -0
- package/dist/handlers/resources/team.d.ts.map +1 -0
- package/dist/handlers/resources/team.js +119 -0
- package/dist/handlers/resources/team.js.map +1 -0
- package/dist/handlers/resources/templates.d.ts +13 -0
- package/dist/handlers/resources/templates.d.ts.map +1 -0
- package/dist/handlers/resources/templates.js +310 -0
- package/dist/handlers/resources/templates.js.map +1 -0
- package/dist/handlers/tools/admin.d.ts +8 -0
- package/dist/handlers/tools/admin.d.ts.map +1 -0
- package/dist/handlers/tools/admin.js +270 -0
- package/dist/handlers/tools/admin.js.map +1 -0
- package/dist/handlers/tools/analytics.d.ts +8 -0
- package/dist/handlers/tools/analytics.d.ts.map +1 -0
- package/dist/handlers/tools/analytics.js +256 -0
- package/dist/handlers/tools/analytics.js.map +1 -0
- package/dist/handlers/tools/backup.d.ts +8 -0
- package/dist/handlers/tools/backup.d.ts.map +1 -0
- package/dist/handlers/tools/backup.js +224 -0
- package/dist/handlers/tools/backup.js.map +1 -0
- package/dist/handlers/tools/core.d.ts +9 -0
- package/dist/handlers/tools/core.d.ts.map +1 -0
- package/dist/handlers/tools/core.js +326 -0
- package/dist/handlers/tools/core.js.map +1 -0
- package/dist/handlers/tools/export.d.ts +8 -0
- package/dist/handlers/tools/export.d.ts.map +1 -0
- package/dist/handlers/tools/export.js +89 -0
- package/dist/handlers/tools/export.js.map +1 -0
- package/dist/handlers/tools/github/helpers.d.ts +34 -0
- package/dist/handlers/tools/github/helpers.d.ts.map +1 -0
- package/dist/handlers/tools/github/helpers.js +52 -0
- package/dist/handlers/tools/github/helpers.js.map +1 -0
- package/dist/handlers/tools/github/insights-tools.d.ts +8 -0
- package/dist/handlers/tools/github/insights-tools.d.ts.map +1 -0
- package/dist/handlers/tools/github/insights-tools.js +104 -0
- package/dist/handlers/tools/github/insights-tools.js.map +1 -0
- package/dist/handlers/tools/github/issue-tools.d.ts +8 -0
- package/dist/handlers/tools/github/issue-tools.d.ts.map +1 -0
- package/dist/handlers/tools/github/issue-tools.js +359 -0
- package/dist/handlers/tools/github/issue-tools.js.map +1 -0
- package/dist/handlers/tools/github/kanban-tools.d.ts +8 -0
- package/dist/handlers/tools/github/kanban-tools.d.ts.map +1 -0
- package/dist/handlers/tools/github/kanban-tools.js +108 -0
- package/dist/handlers/tools/github/kanban-tools.js.map +1 -0
- package/dist/handlers/tools/github/milestone-tools.d.ts +9 -0
- package/dist/handlers/tools/github/milestone-tools.d.ts.map +1 -0
- package/dist/handlers/tools/github/milestone-tools.js +302 -0
- package/dist/handlers/tools/github/milestone-tools.js.map +1 -0
- package/dist/handlers/tools/github/mutation-tools.d.ts +12 -0
- package/dist/handlers/tools/github/mutation-tools.d.ts.map +1 -0
- package/dist/handlers/tools/github/mutation-tools.js +15 -0
- package/dist/handlers/tools/github/mutation-tools.js.map +1 -0
- package/dist/handlers/tools/github/read-tools.d.ts +8 -0
- package/dist/handlers/tools/github/read-tools.d.ts.map +1 -0
- package/dist/handlers/tools/github/read-tools.js +260 -0
- package/dist/handlers/tools/github/read-tools.js.map +1 -0
- package/dist/handlers/tools/github/schemas.d.ts +467 -0
- package/dist/handlers/tools/github/schemas.d.ts.map +1 -0
- package/dist/handlers/tools/github/schemas.js +335 -0
- package/dist/handlers/tools/github/schemas.js.map +1 -0
- package/dist/handlers/tools/github.d.ts +14 -0
- package/dist/handlers/tools/github.d.ts.map +1 -0
- package/dist/handlers/tools/github.js +28 -0
- package/dist/handlers/tools/github.js.map +1 -0
- package/dist/handlers/tools/index.d.ts +15 -20
- package/dist/handlers/tools/index.d.ts.map +1 -1
- package/dist/handlers/tools/index.js +117 -2909
- package/dist/handlers/tools/index.js.map +1 -1
- package/dist/handlers/tools/relationships.d.ts +8 -0
- package/dist/handlers/tools/relationships.d.ts.map +1 -0
- package/dist/handlers/tools/relationships.js +308 -0
- package/dist/handlers/tools/relationships.js.map +1 -0
- package/dist/handlers/tools/schemas.d.ts +108 -0
- package/dist/handlers/tools/schemas.d.ts.map +1 -0
- package/dist/handlers/tools/schemas.js +122 -0
- package/dist/handlers/tools/schemas.js.map +1 -0
- package/dist/handlers/tools/search.d.ts +8 -0
- package/dist/handlers/tools/search.d.ts.map +1 -0
- package/dist/handlers/tools/search.js +282 -0
- package/dist/handlers/tools/search.js.map +1 -0
- package/dist/handlers/tools/team.d.ts +11 -0
- package/dist/handlers/tools/team.d.ts.map +1 -0
- package/dist/handlers/tools/team.js +239 -0
- package/dist/handlers/tools/team.js.map +1 -0
- package/dist/server/McpServer.d.ts +4 -0
- package/dist/server/McpServer.d.ts.map +1 -1
- package/dist/server/McpServer.js +48 -297
- package/dist/server/McpServer.js.map +1 -1
- package/dist/server/Scheduler.d.ts +91 -0
- package/dist/server/Scheduler.d.ts.map +1 -0
- package/dist/server/Scheduler.js +201 -0
- package/dist/server/Scheduler.js.map +1 -0
- package/dist/transports/http.d.ts +66 -0
- package/dist/transports/http.d.ts.map +1 -0
- package/dist/transports/http.js +519 -0
- package/dist/transports/http.js.map +1 -0
- package/dist/types/entities.d.ts +101 -0
- package/dist/types/entities.d.ts.map +1 -0
- package/dist/types/entities.js +5 -0
- package/dist/types/entities.js.map +1 -0
- package/dist/types/filtering.d.ts +34 -0
- package/dist/types/filtering.d.ts.map +1 -0
- package/dist/types/filtering.js +5 -0
- package/dist/types/filtering.js.map +1 -0
- package/dist/types/github.d.ts +166 -0
- package/dist/types/github.d.ts.map +1 -0
- package/dist/types/github.js +5 -0
- package/dist/types/github.js.map +1 -0
- package/dist/types/index.d.ts +35 -292
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +2 -2
- package/dist/types/index.js.map +1 -1
- package/dist/utils/error-helpers.d.ts +37 -0
- package/dist/utils/error-helpers.d.ts.map +1 -0
- package/dist/utils/error-helpers.js +47 -0
- package/dist/utils/error-helpers.js.map +1 -0
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +6 -3
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/security-utils.d.ts +0 -21
- package/dist/utils/security-utils.d.ts.map +1 -1
- package/dist/utils/security-utils.js +0 -47
- package/dist/utils/security-utils.js.map +1 -1
- package/dist/vector/VectorSearchManager.d.ts.map +1 -1
- package/dist/vector/VectorSearchManager.js +9 -32
- package/dist/vector/VectorSearchManager.js.map +1 -1
- package/docker-compose.yml +11 -2
- package/hooks/README.md +107 -0
- package/hooks/cursor/hooks.json +10 -0
- package/hooks/cursor/memory-journal.mdc +22 -0
- package/hooks/cursor/session-end.sh +19 -0
- package/hooks/kilo-code/session-end-mode.json +11 -0
- package/hooks/kiro/session-end.md +13 -0
- package/mcp-config-example.json +1 -0
- package/package.json +11 -9
- package/playwright.config.ts +29 -0
- package/releases/v4.5.0.md +116 -0
- package/releases/v5.0.0.md +105 -0
- package/scripts/generate-server-instructions.ts +176 -0
- package/scripts/server-instructions-function-body.ts +77 -0
- package/server.json +3 -3
- package/src/cli.ts +45 -1
- package/src/constants/ServerInstructions.ts +133 -73
- package/src/constants/icons.ts +8 -7
- package/src/constants/server-instructions.md +268 -0
- package/src/database/SqliteAdapter.ts +358 -192
- package/src/database/schema.ts +125 -0
- package/src/filtering/ToolFilter.ts +13 -2
- package/src/github/GitHubIntegration.ts +1 -3
- package/src/handlers/prompts/github.ts +209 -0
- package/src/handlers/prompts/index.ts +10 -499
- package/src/handlers/prompts/workflow.ts +314 -0
- package/src/handlers/resources/core.ts +528 -0
- package/src/handlers/resources/github.ts +358 -0
- package/src/handlers/resources/graph.ts +254 -0
- package/src/handlers/resources/index.ts +23 -1570
- package/src/handlers/resources/shared.ts +103 -0
- package/src/handlers/resources/team.ts +133 -0
- package/src/handlers/resources/templates.ts +374 -0
- package/src/handlers/tools/admin.ts +285 -0
- package/src/handlers/tools/analytics.ts +301 -0
- package/src/handlers/tools/backup.ts +242 -0
- package/src/handlers/tools/core.ts +350 -0
- package/src/handlers/tools/export.ts +115 -0
- package/src/handlers/tools/github/helpers.ts +86 -0
- package/src/handlers/tools/github/insights-tools.ts +119 -0
- package/src/handlers/tools/github/issue-tools.ts +439 -0
- package/src/handlers/tools/github/kanban-tools.ts +134 -0
- package/src/handlers/tools/github/milestone-tools.ts +392 -0
- package/src/handlers/tools/github/mutation-tools.ts +17 -0
- package/src/handlers/tools/github/read-tools.ts +328 -0
- package/src/handlers/tools/github/schemas.ts +369 -0
- package/src/handlers/tools/github.ts +36 -0
- package/src/handlers/tools/index.ts +144 -3325
- package/src/handlers/tools/relationships.ts +358 -0
- package/src/handlers/tools/schemas.ts +132 -0
- package/src/handlers/tools/search.ts +343 -0
- package/src/handlers/tools/team.ts +273 -0
- package/src/server/McpServer.ts +63 -358
- package/src/server/Scheduler.ts +278 -0
- package/src/transports/http.ts +635 -0
- package/src/types/entities.ts +145 -0
- package/src/types/filtering.ts +54 -0
- package/src/types/github.ts +180 -0
- package/src/types/index.ts +67 -375
- package/src/utils/error-helpers.ts +52 -0
- package/src/utils/logger.ts +6 -3
- package/src/utils/security-utils.ts +0 -52
- package/src/vector/VectorSearchManager.ts +9 -33
- package/tests/constants/icons.test.ts +1 -2
- package/tests/constants/server-instructions.test.ts +30 -4
- package/tests/database/sqlite-adapter.test.ts +91 -7
- package/tests/e2e/auth.spec.ts +154 -0
- package/tests/e2e/health.spec.ts +63 -0
- package/tests/e2e/protocols.spec.ts +134 -0
- package/tests/e2e/resources.spec.ts +103 -0
- package/tests/e2e/scheduler.spec.ts +79 -0
- package/tests/e2e/security.spec.ts +91 -0
- package/tests/e2e/sessions.spec.ts +95 -0
- package/tests/e2e/stateless.spec.ts +121 -0
- package/tests/e2e/tools.spec.ts +111 -0
- package/tests/filtering/tool-filter.test.ts +46 -0
- package/tests/handlers/error-path-coverage.test.ts +324 -0
- package/tests/handlers/github-resource-handlers.test.ts +453 -0
- package/tests/handlers/github-tool-handlers.test.ts +899 -0
- package/tests/handlers/prompt-handler-coverage.test.ts +106 -0
- package/tests/handlers/prompt-handlers.test.ts +40 -0
- package/tests/handlers/resource-handler-coverage.test.ts +181 -0
- package/tests/handlers/resource-handlers.test.ts +33 -9
- package/tests/handlers/search-tool-handlers.test.ts +272 -0
- package/tests/handlers/targeted-gap-closure.test.ts +387 -0
- package/tests/handlers/team-resource-handlers.test.ts +156 -0
- package/tests/handlers/team-tool-handlers.test.ts +301 -0
- package/tests/handlers/tool-handler-coverage.test.ts +469 -0
- package/tests/handlers/tool-handlers.test.ts +2 -2
- package/tests/security/sql-injection.test.ts +3 -54
- package/tests/server/mcp-server.test.ts +503 -8
- package/tests/server/scheduler.test.ts +400 -0
- package/tests/transports/http-transport.test.ts +620 -0
- package/tests/vector/vector-search-manager.test.ts +60 -0
- package/vitest.config.ts +4 -1
- package/.memory-journal-team.db +0 -0
- package/.vscode/settings.json +0 -84
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search Tool Group - 4 tools
|
|
3
|
+
*
|
|
4
|
+
* Tools: search_entries, search_by_date_range, semantic_search, get_vector_index_stats
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
import type { ToolDefinition, ToolContext } from '../../types/index.js'
|
|
9
|
+
import { formatHandlerError } from '../../utils/error-helpers.js'
|
|
10
|
+
import {
|
|
11
|
+
ENTRY_TYPES,
|
|
12
|
+
DATE_FORMAT_REGEX,
|
|
13
|
+
DATE_FORMAT_MESSAGE,
|
|
14
|
+
EntryOutputSchema,
|
|
15
|
+
EntriesListOutputSchema,
|
|
16
|
+
} from './schemas.js'
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Input Schemas
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
/** Strict schema — used inside handler for structured Zod errors */
|
|
23
|
+
const SearchEntriesSchema = z.object({
|
|
24
|
+
query: z.string().optional(),
|
|
25
|
+
limit: z.number().max(500).optional().default(10),
|
|
26
|
+
is_personal: z.boolean().optional(),
|
|
27
|
+
project_number: z.number().optional(),
|
|
28
|
+
issue_number: z.number().optional(),
|
|
29
|
+
pr_number: z.number().optional(),
|
|
30
|
+
pr_status: z.enum(['draft', 'open', 'merged', 'closed']).optional(),
|
|
31
|
+
workflow_run_id: z.number().optional(),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/** Relaxed schema — passed to SDK inputSchema so Zod enum errors reach the handler */
|
|
35
|
+
const SearchEntriesSchemaMcp = z.object({
|
|
36
|
+
query: z.string().optional(),
|
|
37
|
+
limit: z.number().max(500).optional().default(10),
|
|
38
|
+
is_personal: z.boolean().optional(),
|
|
39
|
+
project_number: z.number().optional(),
|
|
40
|
+
issue_number: z.number().optional(),
|
|
41
|
+
pr_number: z.number().optional(),
|
|
42
|
+
pr_status: z.string().optional(),
|
|
43
|
+
workflow_run_id: z.number().optional(),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/** Strict schema — used inside handler for structured Zod errors */
|
|
47
|
+
const SearchByDateRangeSchema = z.object({
|
|
48
|
+
start_date: z.string().regex(DATE_FORMAT_REGEX, DATE_FORMAT_MESSAGE),
|
|
49
|
+
end_date: z.string().regex(DATE_FORMAT_REGEX, DATE_FORMAT_MESSAGE),
|
|
50
|
+
entry_type: z.enum(ENTRY_TYPES).optional(),
|
|
51
|
+
tags: z.array(z.string()).optional(),
|
|
52
|
+
is_personal: z.boolean().optional(),
|
|
53
|
+
project_number: z.number().optional(),
|
|
54
|
+
issue_number: z.number().optional(),
|
|
55
|
+
pr_number: z.number().optional(),
|
|
56
|
+
workflow_run_id: z.number().optional(),
|
|
57
|
+
limit: z.number().max(500).optional().default(500),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/** Relaxed schema — passed to SDK inputSchema so Zod errors reach the handler */
|
|
61
|
+
const SearchByDateRangeSchemaMcp = z.object({
|
|
62
|
+
start_date: z.string(),
|
|
63
|
+
end_date: z.string(),
|
|
64
|
+
entry_type: z.string().optional(),
|
|
65
|
+
tags: z.array(z.string()).optional(),
|
|
66
|
+
is_personal: z.boolean().optional(),
|
|
67
|
+
project_number: z.number().optional(),
|
|
68
|
+
issue_number: z.number().optional(),
|
|
69
|
+
pr_number: z.number().optional(),
|
|
70
|
+
workflow_run_id: z.number().optional(),
|
|
71
|
+
limit: z.number().max(500).optional().default(500),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const SemanticSearchSchema = z.object({
|
|
75
|
+
query: z.string(),
|
|
76
|
+
limit: z.number().max(500).optional().default(10),
|
|
77
|
+
similarity_threshold: z.number().optional().default(0.25),
|
|
78
|
+
is_personal: z.boolean().optional(),
|
|
79
|
+
hint_on_empty: z
|
|
80
|
+
.boolean()
|
|
81
|
+
.optional()
|
|
82
|
+
.default(true)
|
|
83
|
+
.describe('Include hint when no results found (default: true)'),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// Output Schemas
|
|
88
|
+
// ============================================================================
|
|
89
|
+
|
|
90
|
+
const SemanticEntryOutputSchema = EntryOutputSchema.extend({
|
|
91
|
+
similarity: z.number(),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const SemanticSearchOutputSchema = z.object({
|
|
95
|
+
query: z.string().optional(),
|
|
96
|
+
entries: z.array(SemanticEntryOutputSchema).optional(),
|
|
97
|
+
count: z.number().optional(),
|
|
98
|
+
hint: z.string().optional(),
|
|
99
|
+
success: z.boolean().optional(),
|
|
100
|
+
error: z.string().optional(),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const VectorStatsOutputSchema = z.object({
|
|
104
|
+
available: z.boolean(),
|
|
105
|
+
error: z.string().optional(),
|
|
106
|
+
entryCount: z.number().optional(),
|
|
107
|
+
indexSize: z.number().optional(),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// ============================================================================
|
|
111
|
+
// Tool Definitions
|
|
112
|
+
// ============================================================================
|
|
113
|
+
|
|
114
|
+
export function getSearchTools(context: ToolContext): ToolDefinition[] {
|
|
115
|
+
const { db, teamDb, vectorManager } = context
|
|
116
|
+
return [
|
|
117
|
+
{
|
|
118
|
+
name: 'search_entries',
|
|
119
|
+
title: 'Search Entries',
|
|
120
|
+
description:
|
|
121
|
+
'Search journal entries with optional filters for GitHub Projects, Issues, PRs, and Actions',
|
|
122
|
+
group: 'search',
|
|
123
|
+
inputSchema: SearchEntriesSchemaMcp,
|
|
124
|
+
outputSchema: EntriesListOutputSchema,
|
|
125
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
126
|
+
handler: (params: unknown) => {
|
|
127
|
+
try {
|
|
128
|
+
const input = SearchEntriesSchema.parse(params)
|
|
129
|
+
const hasFilters =
|
|
130
|
+
input.project_number !== undefined ||
|
|
131
|
+
input.issue_number !== undefined ||
|
|
132
|
+
input.pr_number !== undefined ||
|
|
133
|
+
input.is_personal !== undefined
|
|
134
|
+
|
|
135
|
+
let personalEntries
|
|
136
|
+
if (!input.query && !hasFilters) {
|
|
137
|
+
personalEntries = db.getRecentEntries(input.limit, input.is_personal)
|
|
138
|
+
} else {
|
|
139
|
+
personalEntries = db.searchEntries(input.query || '', {
|
|
140
|
+
limit: input.limit,
|
|
141
|
+
isPersonal: input.is_personal,
|
|
142
|
+
projectNumber: input.project_number,
|
|
143
|
+
issueNumber: input.issue_number,
|
|
144
|
+
prNumber: input.pr_number,
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Cross-database merge when team DB is available
|
|
149
|
+
if (teamDb) {
|
|
150
|
+
let teamEntries
|
|
151
|
+
if (!input.query && !hasFilters) {
|
|
152
|
+
teamEntries = teamDb.getRecentEntries(input.limit)
|
|
153
|
+
} else {
|
|
154
|
+
teamEntries = teamDb.searchEntries(input.query || '', {
|
|
155
|
+
limit: input.limit,
|
|
156
|
+
projectNumber: input.project_number,
|
|
157
|
+
issueNumber: input.issue_number,
|
|
158
|
+
prNumber: input.pr_number,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
const merged = mergeAndDedup(
|
|
162
|
+
personalEntries.map((e) => ({ ...e, source: 'personal' as const })),
|
|
163
|
+
teamEntries.map((e) => ({ ...e, source: 'team' as const })),
|
|
164
|
+
input.limit
|
|
165
|
+
)
|
|
166
|
+
return { entries: merged, count: merged.length }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { entries: personalEntries, count: personalEntries.length }
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return formatHandlerError(err)
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: 'search_by_date_range',
|
|
177
|
+
title: 'Search by Date Range',
|
|
178
|
+
description: 'Search journal entries within a date range with optional filters',
|
|
179
|
+
group: 'search',
|
|
180
|
+
inputSchema: SearchByDateRangeSchemaMcp,
|
|
181
|
+
outputSchema: EntriesListOutputSchema,
|
|
182
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
183
|
+
handler: (params: unknown) => {
|
|
184
|
+
try {
|
|
185
|
+
const input = SearchByDateRangeSchema.parse(params)
|
|
186
|
+
const personalEntries = db.searchByDateRange(input.start_date, input.end_date, {
|
|
187
|
+
entryType: input.entry_type,
|
|
188
|
+
tags: input.tags,
|
|
189
|
+
isPersonal: input.is_personal,
|
|
190
|
+
projectNumber: input.project_number,
|
|
191
|
+
limit: input.limit,
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// Cross-database merge when team DB is available
|
|
195
|
+
if (teamDb) {
|
|
196
|
+
const teamEntries = teamDb.searchByDateRange(
|
|
197
|
+
input.start_date,
|
|
198
|
+
input.end_date,
|
|
199
|
+
{
|
|
200
|
+
entryType: input.entry_type,
|
|
201
|
+
tags: input.tags,
|
|
202
|
+
projectNumber: input.project_number,
|
|
203
|
+
limit: input.limit,
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
const merged = mergeAndDedup(
|
|
207
|
+
personalEntries.map((e) => ({ ...e, source: 'personal' as const })),
|
|
208
|
+
teamEntries.map((e) => ({ ...e, source: 'team' as const })),
|
|
209
|
+
input.limit
|
|
210
|
+
)
|
|
211
|
+
return { entries: merged, count: merged.length }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { entries: personalEntries, count: personalEntries.length }
|
|
215
|
+
} catch (err) {
|
|
216
|
+
return formatHandlerError(err)
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: 'semantic_search',
|
|
222
|
+
title: 'Semantic Search',
|
|
223
|
+
description: 'Perform semantic/vector search on journal entries using AI embeddings',
|
|
224
|
+
group: 'search',
|
|
225
|
+
inputSchema: SemanticSearchSchema,
|
|
226
|
+
outputSchema: SemanticSearchOutputSchema,
|
|
227
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
228
|
+
handler: async (params: unknown) => {
|
|
229
|
+
try {
|
|
230
|
+
const input = SemanticSearchSchema.parse(params)
|
|
231
|
+
|
|
232
|
+
if (!vectorManager) {
|
|
233
|
+
return {
|
|
234
|
+
success: false,
|
|
235
|
+
error: 'Semantic search not initialized. Vector search manager is not available.',
|
|
236
|
+
query: input.query,
|
|
237
|
+
entries: [],
|
|
238
|
+
count: 0,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const results = await vectorManager.search(
|
|
243
|
+
input.query,
|
|
244
|
+
input.limit ?? 10,
|
|
245
|
+
input.similarity_threshold ?? 0.25
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
const entries = results
|
|
249
|
+
.map((r) => {
|
|
250
|
+
const entry = db.getEntryById(r.entryId)
|
|
251
|
+
if (!entry) return null
|
|
252
|
+
return {
|
|
253
|
+
...entry,
|
|
254
|
+
similarity: Math.round(r.score * 100) / 100,
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
.filter((e): e is NonNullable<typeof e> => e !== null)
|
|
258
|
+
|
|
259
|
+
const stats = await vectorManager.getStats()
|
|
260
|
+
const isIndexEmpty = stats.itemCount === 0
|
|
261
|
+
const includeHint = input.hint_on_empty ?? true
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
query: input.query,
|
|
265
|
+
entries,
|
|
266
|
+
count: entries.length,
|
|
267
|
+
...(includeHint && isIndexEmpty
|
|
268
|
+
? {
|
|
269
|
+
hint: 'No entries in vector index. Use rebuild_vector_index to index existing entries.',
|
|
270
|
+
}
|
|
271
|
+
: includeHint && entries.length === 0
|
|
272
|
+
? {
|
|
273
|
+
hint: `No entries matched your query above the similarity threshold (${String(input.similarity_threshold ?? 0.25)}). Try lowering similarity_threshold (e.g., 0.15) for broader matches.`,
|
|
274
|
+
}
|
|
275
|
+
: {}),
|
|
276
|
+
}
|
|
277
|
+
} catch (err) {
|
|
278
|
+
return formatHandlerError(err)
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
name: 'get_vector_index_stats',
|
|
284
|
+
title: 'Get Vector Index Stats',
|
|
285
|
+
description: 'Get statistics about the semantic search vector index',
|
|
286
|
+
group: 'search',
|
|
287
|
+
inputSchema: z.object({}),
|
|
288
|
+
outputSchema: VectorStatsOutputSchema,
|
|
289
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
290
|
+
handler: async (_params: unknown) => {
|
|
291
|
+
try {
|
|
292
|
+
if (!vectorManager) {
|
|
293
|
+
return { available: false, error: 'Vector search not available' }
|
|
294
|
+
}
|
|
295
|
+
const stats = await vectorManager.getStats()
|
|
296
|
+
return { available: true, ...stats }
|
|
297
|
+
} catch (err) {
|
|
298
|
+
return formatHandlerError(err)
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
]
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ============================================================================
|
|
306
|
+
// Helpers
|
|
307
|
+
// ============================================================================
|
|
308
|
+
|
|
309
|
+
interface EntryWithSource {
|
|
310
|
+
content: string
|
|
311
|
+
timestamp: string
|
|
312
|
+
source: 'personal' | 'team'
|
|
313
|
+
[key: string]: unknown
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Merge personal and team results, deduplicate by content,
|
|
318
|
+
* and sort by timestamp descending.
|
|
319
|
+
*/
|
|
320
|
+
function mergeAndDedup(
|
|
321
|
+
personal: EntryWithSource[],
|
|
322
|
+
team: EntryWithSource[],
|
|
323
|
+
limit?: number
|
|
324
|
+
): EntryWithSource[] {
|
|
325
|
+
const seen = new Set<string>()
|
|
326
|
+
const merged: EntryWithSource[] = []
|
|
327
|
+
|
|
328
|
+
// Concat and sort by timestamp descending
|
|
329
|
+
const all = [...personal, ...team].sort(
|
|
330
|
+
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
for (const entry of all) {
|
|
334
|
+
// Deduplicate by content (same entry shared to team)
|
|
335
|
+
const key = entry.content.slice(0, 200)
|
|
336
|
+
if (!seen.has(key)) {
|
|
337
|
+
seen.add(key)
|
|
338
|
+
merged.push(entry)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return limit !== undefined ? merged.slice(0, limit) : merged
|
|
343
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team Tool Group - 3 tools
|
|
3
|
+
*
|
|
4
|
+
* Tools: team_create_entry, team_get_recent, team_search
|
|
5
|
+
*
|
|
6
|
+
* Requires TEAM_DB_PATH to be configured. All tools return structured
|
|
7
|
+
* errors when the team database is not available.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { z } from 'zod'
|
|
11
|
+
import { execFileSync } from 'node:child_process'
|
|
12
|
+
import type { ToolDefinition, ToolContext } from '../../types/index.js'
|
|
13
|
+
import { formatHandlerError } from '../../utils/error-helpers.js'
|
|
14
|
+
import { ENTRY_TYPES, SIGNIFICANCE_TYPES, EntryOutputSchema } from './schemas.js'
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Author Detection
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the author name for team entries.
|
|
22
|
+
* Priority: TEAM_AUTHOR env > git config user.name > 'unknown'
|
|
23
|
+
*/
|
|
24
|
+
function resolveAuthor(): string {
|
|
25
|
+
// 1. Explicit env var
|
|
26
|
+
const envAuthor = process.env['TEAM_AUTHOR']?.trim().replace(/"/g, '')
|
|
27
|
+
if (envAuthor) return envAuthor
|
|
28
|
+
|
|
29
|
+
// 2. Git config
|
|
30
|
+
try {
|
|
31
|
+
const gitUser = execFileSync('git', ['config', 'user.name'], {
|
|
32
|
+
encoding: 'utf-8',
|
|
33
|
+
timeout: 3000,
|
|
34
|
+
})
|
|
35
|
+
.trim()
|
|
36
|
+
.replace(/"/g, '')
|
|
37
|
+
if (gitUser) return gitUser
|
|
38
|
+
} catch {
|
|
39
|
+
// Git not available or not configured
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return 'unknown'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Input Schemas
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
/** Strict schema for team entry creation */
|
|
50
|
+
const TeamCreateEntrySchema = z.object({
|
|
51
|
+
content: z.string().min(1).max(50000),
|
|
52
|
+
entry_type: z.enum(ENTRY_TYPES).optional().default('personal_reflection'),
|
|
53
|
+
tags: z.array(z.string()).optional().default([]),
|
|
54
|
+
significance_type: z.enum(SIGNIFICANCE_TYPES).optional(),
|
|
55
|
+
project_number: z.number().optional(),
|
|
56
|
+
project_owner: z.string().optional(),
|
|
57
|
+
issue_number: z.number().optional(),
|
|
58
|
+
issue_url: z.string().optional(),
|
|
59
|
+
pr_number: z.number().optional(),
|
|
60
|
+
pr_url: z.string().optional(),
|
|
61
|
+
pr_status: z.enum(['draft', 'open', 'merged', 'closed']).optional(),
|
|
62
|
+
author: z.string().optional(),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/** Relaxed schema for MCP SDK */
|
|
66
|
+
const TeamCreateEntrySchemaMcp = z.object({
|
|
67
|
+
content: z.string().min(1).max(50000),
|
|
68
|
+
entry_type: z.string().optional().default('personal_reflection'),
|
|
69
|
+
tags: z.array(z.string()).optional().default([]),
|
|
70
|
+
significance_type: z.string().optional(),
|
|
71
|
+
project_number: z.number().optional(),
|
|
72
|
+
project_owner: z.string().optional(),
|
|
73
|
+
issue_number: z.number().optional(),
|
|
74
|
+
issue_url: z.string().optional(),
|
|
75
|
+
pr_number: z.number().optional(),
|
|
76
|
+
pr_url: z.string().optional(),
|
|
77
|
+
pr_status: z.string().optional(),
|
|
78
|
+
author: z.string().optional(),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const TeamGetRecentSchema = z.object({
|
|
82
|
+
limit: z.number().max(500).optional().default(10),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const TeamSearchSchema = z.object({
|
|
86
|
+
query: z.string().optional(),
|
|
87
|
+
tags: z.array(z.string()).optional(),
|
|
88
|
+
limit: z.number().max(500).optional().default(10),
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// ============================================================================
|
|
92
|
+
// Output Schemas
|
|
93
|
+
// ============================================================================
|
|
94
|
+
|
|
95
|
+
const TeamEntryOutputSchema = EntryOutputSchema.extend({
|
|
96
|
+
author: z.string().nullable().optional(),
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const TeamCreateOutputSchema = z.object({
|
|
100
|
+
success: z.boolean().optional(),
|
|
101
|
+
entry: TeamEntryOutputSchema.optional(),
|
|
102
|
+
author: z.string().optional(),
|
|
103
|
+
error: z.string().optional(),
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const TeamEntriesListOutputSchema = z.object({
|
|
107
|
+
entries: z.array(TeamEntryOutputSchema).optional(),
|
|
108
|
+
count: z.number().optional(),
|
|
109
|
+
success: z.boolean().optional(),
|
|
110
|
+
error: z.string().optional(),
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// ============================================================================
|
|
114
|
+
// Constants
|
|
115
|
+
// ============================================================================
|
|
116
|
+
|
|
117
|
+
const TEAM_DB_NOT_CONFIGURED =
|
|
118
|
+
'Team database not configured. Set TEAM_DB_PATH environment variable to enable team collaboration.'
|
|
119
|
+
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// Tool Definitions
|
|
122
|
+
// ============================================================================
|
|
123
|
+
|
|
124
|
+
export function getTeamTools(context: ToolContext): ToolDefinition[] {
|
|
125
|
+
const { teamDb, github } = context
|
|
126
|
+
|
|
127
|
+
return [
|
|
128
|
+
{
|
|
129
|
+
name: 'team_create_entry',
|
|
130
|
+
title: 'Create Team Entry',
|
|
131
|
+
description:
|
|
132
|
+
'Create an entry in the team database for sharing with collaborators. Requires TEAM_DB_PATH.',
|
|
133
|
+
group: 'team',
|
|
134
|
+
inputSchema: TeamCreateEntrySchemaMcp,
|
|
135
|
+
outputSchema: TeamCreateOutputSchema,
|
|
136
|
+
annotations: { readOnlyHint: false, idempotentHint: false },
|
|
137
|
+
handler: (params: unknown) => {
|
|
138
|
+
try {
|
|
139
|
+
if (!teamDb) {
|
|
140
|
+
return { success: false, error: TEAM_DB_NOT_CONFIGURED }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const input = TeamCreateEntrySchema.parse(params)
|
|
144
|
+
const author = input.author ?? resolveAuthor()
|
|
145
|
+
|
|
146
|
+
// Auto-populate issueUrl if issue_number provided
|
|
147
|
+
let resolvedIssueUrl = input.issue_url
|
|
148
|
+
if (input.issue_number !== undefined && !input.issue_url && github) {
|
|
149
|
+
const cachedRepo = github.getCachedRepoInfo()
|
|
150
|
+
if (cachedRepo?.owner && cachedRepo?.repo) {
|
|
151
|
+
resolvedIssueUrl = `https://github.com/${cachedRepo.owner}/${cachedRepo.repo}/issues/${String(input.issue_number)}`
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const entry = teamDb.createEntry({
|
|
156
|
+
content: input.content,
|
|
157
|
+
entryType: input.entry_type,
|
|
158
|
+
tags: input.tags,
|
|
159
|
+
isPersonal: false, // Team entries are always project-level
|
|
160
|
+
significanceType: input.significance_type ?? null,
|
|
161
|
+
autoContext: JSON.stringify({ author }),
|
|
162
|
+
projectNumber: input.project_number,
|
|
163
|
+
projectOwner: input.project_owner,
|
|
164
|
+
issueNumber: input.issue_number,
|
|
165
|
+
issueUrl: resolvedIssueUrl,
|
|
166
|
+
prNumber: input.pr_number,
|
|
167
|
+
prUrl: input.pr_url,
|
|
168
|
+
prStatus: input.pr_status,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// Write author to the dedicated column
|
|
172
|
+
const rawDb = teamDb.getRawDb()
|
|
173
|
+
rawDb.run('UPDATE memory_journal SET author = ? WHERE id = ?', [
|
|
174
|
+
author,
|
|
175
|
+
entry.id,
|
|
176
|
+
])
|
|
177
|
+
teamDb.flushSave()
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
success: true,
|
|
181
|
+
entry: { ...entry, author },
|
|
182
|
+
author,
|
|
183
|
+
}
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return formatHandlerError(err)
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: 'team_get_recent',
|
|
191
|
+
title: 'Get Recent Team Entries',
|
|
192
|
+
description: 'Get recent entries from the team database. Requires TEAM_DB_PATH.',
|
|
193
|
+
group: 'team',
|
|
194
|
+
inputSchema: TeamGetRecentSchema,
|
|
195
|
+
outputSchema: TeamEntriesListOutputSchema,
|
|
196
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
197
|
+
handler: (params: unknown) => {
|
|
198
|
+
try {
|
|
199
|
+
if (!teamDb) {
|
|
200
|
+
return { success: false, error: TEAM_DB_NOT_CONFIGURED }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const { limit } = TeamGetRecentSchema.parse(params)
|
|
204
|
+
const entries = teamDb.getRecentEntries(limit)
|
|
205
|
+
|
|
206
|
+
// Enrich entries with author column
|
|
207
|
+
const rawDb = teamDb.getRawDb()
|
|
208
|
+
const enriched = entries.map((e) => {
|
|
209
|
+
const authorResult = rawDb.exec(
|
|
210
|
+
'SELECT author FROM memory_journal WHERE id = ?',
|
|
211
|
+
[e.id]
|
|
212
|
+
)
|
|
213
|
+
const author = (authorResult[0]?.values[0]?.[0] as string) ?? null
|
|
214
|
+
return { ...e, author }
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
return { entries: enriched, count: enriched.length }
|
|
218
|
+
} catch (err) {
|
|
219
|
+
return formatHandlerError(err)
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: 'team_search',
|
|
225
|
+
title: 'Search Team Entries',
|
|
226
|
+
description:
|
|
227
|
+
'Search entries in the team database by text and/or tags. Requires TEAM_DB_PATH.',
|
|
228
|
+
group: 'team',
|
|
229
|
+
inputSchema: TeamSearchSchema,
|
|
230
|
+
outputSchema: TeamEntriesListOutputSchema,
|
|
231
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
232
|
+
handler: (params: unknown) => {
|
|
233
|
+
try {
|
|
234
|
+
if (!teamDb) {
|
|
235
|
+
return { success: false, error: TEAM_DB_NOT_CONFIGURED }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const { query, tags, limit } = TeamSearchSchema.parse(params)
|
|
239
|
+
|
|
240
|
+
let entries
|
|
241
|
+
if (query) {
|
|
242
|
+
entries = teamDb.searchEntries(query, { limit })
|
|
243
|
+
} else {
|
|
244
|
+
entries = teamDb.getRecentEntries(limit)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Filter by tags if provided
|
|
248
|
+
if (tags && tags.length > 0) {
|
|
249
|
+
entries = entries.filter((e) => {
|
|
250
|
+
const entryTags = teamDb.getTagsForEntry(e.id)
|
|
251
|
+
return tags.some((t) => entryTags.includes(t))
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Enrich with author
|
|
256
|
+
const rawDb = teamDb.getRawDb()
|
|
257
|
+
const enriched = entries.map((e) => {
|
|
258
|
+
const authorResult = rawDb.exec(
|
|
259
|
+
'SELECT author FROM memory_journal WHERE id = ?',
|
|
260
|
+
[e.id]
|
|
261
|
+
)
|
|
262
|
+
const author = (authorResult[0]?.values[0]?.[0] as string) ?? null
|
|
263
|
+
return { ...e, author }
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
return { entries: enriched, count: enriched.length }
|
|
267
|
+
} catch (err) {
|
|
268
|
+
return formatHandlerError(err)
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
]
|
|
273
|
+
}
|