engrm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.mcp.json +9 -0
  2. package/AUTH-DESIGN.md +436 -0
  3. package/BRIEF.md +197 -0
  4. package/CLAUDE.md +44 -0
  5. package/COMPETITIVE.md +174 -0
  6. package/CONTEXT-OPTIMIZATION.md +305 -0
  7. package/INFRASTRUCTURE.md +252 -0
  8. package/LICENSE +105 -0
  9. package/MARKET.md +230 -0
  10. package/PLAN.md +278 -0
  11. package/README.md +121 -0
  12. package/SENTINEL.md +293 -0
  13. package/SERVER-API-PLAN.md +553 -0
  14. package/SPEC.md +843 -0
  15. package/SWOT.md +148 -0
  16. package/SYNC-ARCHITECTURE.md +294 -0
  17. package/VIBE-CODER-STRATEGY.md +250 -0
  18. package/bun.lock +375 -0
  19. package/hooks/post-tool-use.ts +144 -0
  20. package/hooks/session-start.ts +64 -0
  21. package/hooks/stop.ts +131 -0
  22. package/mem-page.html +1305 -0
  23. package/package.json +30 -0
  24. package/src/capture/dedup.test.ts +103 -0
  25. package/src/capture/dedup.ts +76 -0
  26. package/src/capture/extractor.test.ts +245 -0
  27. package/src/capture/extractor.ts +330 -0
  28. package/src/capture/quality.test.ts +168 -0
  29. package/src/capture/quality.ts +104 -0
  30. package/src/capture/retrospective.test.ts +115 -0
  31. package/src/capture/retrospective.ts +121 -0
  32. package/src/capture/scanner.test.ts +131 -0
  33. package/src/capture/scanner.ts +100 -0
  34. package/src/capture/scrubber.test.ts +144 -0
  35. package/src/capture/scrubber.ts +181 -0
  36. package/src/cli.ts +517 -0
  37. package/src/config.ts +238 -0
  38. package/src/context/inject.test.ts +940 -0
  39. package/src/context/inject.ts +382 -0
  40. package/src/embeddings/backfill.ts +50 -0
  41. package/src/embeddings/embedder.test.ts +76 -0
  42. package/src/embeddings/embedder.ts +139 -0
  43. package/src/lifecycle/aging.test.ts +103 -0
  44. package/src/lifecycle/aging.ts +36 -0
  45. package/src/lifecycle/compaction.test.ts +264 -0
  46. package/src/lifecycle/compaction.ts +190 -0
  47. package/src/lifecycle/purge.test.ts +100 -0
  48. package/src/lifecycle/purge.ts +37 -0
  49. package/src/lifecycle/scheduler.test.ts +120 -0
  50. package/src/lifecycle/scheduler.ts +101 -0
  51. package/src/provisioning/browser-auth.ts +172 -0
  52. package/src/provisioning/provision.test.ts +198 -0
  53. package/src/provisioning/provision.ts +94 -0
  54. package/src/register.test.ts +167 -0
  55. package/src/register.ts +178 -0
  56. package/src/server.ts +436 -0
  57. package/src/storage/migrations.test.ts +244 -0
  58. package/src/storage/migrations.ts +261 -0
  59. package/src/storage/outbox.test.ts +229 -0
  60. package/src/storage/outbox.ts +131 -0
  61. package/src/storage/projects.test.ts +137 -0
  62. package/src/storage/projects.ts +184 -0
  63. package/src/storage/sqlite.test.ts +798 -0
  64. package/src/storage/sqlite.ts +934 -0
  65. package/src/storage/vec.test.ts +198 -0
  66. package/src/sync/auth.test.ts +76 -0
  67. package/src/sync/auth.ts +68 -0
  68. package/src/sync/client.ts +183 -0
  69. package/src/sync/engine.test.ts +94 -0
  70. package/src/sync/engine.ts +127 -0
  71. package/src/sync/pull.test.ts +279 -0
  72. package/src/sync/pull.ts +170 -0
  73. package/src/sync/push.test.ts +117 -0
  74. package/src/sync/push.ts +230 -0
  75. package/src/tools/get.ts +34 -0
  76. package/src/tools/pin.ts +47 -0
  77. package/src/tools/save.test.ts +301 -0
  78. package/src/tools/save.ts +231 -0
  79. package/src/tools/search.test.ts +69 -0
  80. package/src/tools/search.ts +181 -0
  81. package/src/tools/timeline.ts +64 -0
  82. package/tsconfig.json +22 -0
@@ -0,0 +1,231 @@
1
+ /**
2
+ * save_observation MCP tool.
3
+ *
4
+ * Pipeline: detect project → scrub secrets → score quality →
5
+ * check dedup → insert → add to outbox
6
+ */
7
+
8
+ import { relative, isAbsolute } from "node:path";
9
+ import type { Config } from "../config.js";
10
+ import { scrubSecrets, containsSecrets } from "../capture/scrubber.js";
11
+ import { scoreQuality, meetsQualityThreshold } from "../capture/quality.js";
12
+ import { findDuplicate, type DedupCandidate } from "../capture/dedup.js";
13
+ import { detectProject } from "../storage/projects.js";
14
+ import type { MemDatabase, ObservationRow } from "../storage/sqlite.js";
15
+ import { composeEmbeddingText, embedText } from "../embeddings/embedder.js";
16
+
17
+ const VALID_TYPES = [
18
+ "bugfix",
19
+ "discovery",
20
+ "decision",
21
+ "pattern",
22
+ "change",
23
+ "feature",
24
+ "refactor",
25
+ "digest",
26
+ ] as const;
27
+
28
+ type ObservationType = (typeof VALID_TYPES)[number];
29
+
30
+ export interface SaveObservationInput {
31
+ type: string;
32
+ title: string;
33
+ narrative?: string;
34
+ facts?: string[];
35
+ concepts?: string[];
36
+ files_read?: string[];
37
+ files_modified?: string[];
38
+ sensitivity?: "shared" | "personal" | "secret";
39
+ session_id?: string;
40
+ cwd?: string;
41
+ agent?: string;
42
+ }
43
+
44
+ export interface SaveObservationResult {
45
+ success: boolean;
46
+ observation_id?: number;
47
+ quality_score?: number;
48
+ merged_into?: number;
49
+ reason?: string;
50
+ }
51
+
52
+ /**
53
+ * Save an observation through the full capture pipeline.
54
+ */
55
+ export async function saveObservation(
56
+ db: MemDatabase,
57
+ config: Config,
58
+ input: SaveObservationInput
59
+ ): Promise<SaveObservationResult> {
60
+ // Validate type
61
+ if (!VALID_TYPES.includes(input.type as ObservationType)) {
62
+ return {
63
+ success: false,
64
+ reason: `Invalid type '${input.type}'. Must be one of: ${VALID_TYPES.join(", ")}`,
65
+ };
66
+ }
67
+
68
+ // Validate title
69
+ if (!input.title || input.title.trim().length === 0) {
70
+ return { success: false, reason: "Title is required" };
71
+ }
72
+
73
+ // Detect project from cwd
74
+ const cwd = input.cwd ?? process.cwd();
75
+ const detected = detectProject(cwd);
76
+ const project = db.upsertProject({
77
+ canonical_id: detected.canonical_id,
78
+ name: detected.name,
79
+ local_path: detected.local_path,
80
+ remote_url: detected.remote_url,
81
+ });
82
+
83
+ // Scrub secrets from all text fields
84
+ const customPatterns = config.scrubbing.enabled
85
+ ? config.scrubbing.custom_patterns
86
+ : [];
87
+
88
+ const title = config.scrubbing.enabled
89
+ ? scrubSecrets(input.title, customPatterns)
90
+ : input.title;
91
+
92
+ const narrative = input.narrative
93
+ ? config.scrubbing.enabled
94
+ ? scrubSecrets(input.narrative, customPatterns)
95
+ : input.narrative
96
+ : null;
97
+
98
+ const factsJson = input.facts
99
+ ? config.scrubbing.enabled
100
+ ? scrubSecrets(JSON.stringify(input.facts), customPatterns)
101
+ : JSON.stringify(input.facts)
102
+ : null;
103
+
104
+ const conceptsJson = input.concepts
105
+ ? JSON.stringify(input.concepts)
106
+ : null;
107
+
108
+ // Convert absolute paths to project-relative for cross-machine portability
109
+ const filesRead = input.files_read
110
+ ? input.files_read.map((f) => toRelativePath(f, cwd))
111
+ : null;
112
+
113
+ const filesModified = input.files_modified
114
+ ? input.files_modified.map((f) => toRelativePath(f, cwd))
115
+ : null;
116
+
117
+ const filesReadJson = filesRead ? JSON.stringify(filesRead) : null;
118
+ const filesModifiedJson = filesModified ? JSON.stringify(filesModified) : null;
119
+
120
+ // Determine sensitivity
121
+ let sensitivity = input.sensitivity ?? config.scrubbing.default_sensitivity;
122
+ if (
123
+ config.scrubbing.enabled &&
124
+ containsSecrets(
125
+ [input.title, input.narrative, JSON.stringify(input.facts)]
126
+ .filter(Boolean)
127
+ .join(" "),
128
+ customPatterns
129
+ )
130
+ ) {
131
+ // Upgrade to 'personal' if secrets detected (even after scrubbing, flag it)
132
+ if (sensitivity === "shared") {
133
+ sensitivity = "personal";
134
+ }
135
+ }
136
+
137
+ // Check deduplication against last 24h
138
+ const oneDayAgo = Math.floor(Date.now() / 1000) - 86400;
139
+ const recentObs = db.getRecentObservations(project.id, oneDayAgo);
140
+ const candidates: DedupCandidate[] = recentObs.map((o) => ({
141
+ id: o.id,
142
+ title: o.title,
143
+ }));
144
+ const duplicate = findDuplicate(title, candidates);
145
+
146
+ // Score quality
147
+ const qualityInput = {
148
+ type: input.type,
149
+ title,
150
+ narrative,
151
+ facts: factsJson,
152
+ concepts: conceptsJson,
153
+ filesRead: filesRead,
154
+ filesModified: filesModified,
155
+ isDuplicate: duplicate !== null,
156
+ };
157
+
158
+ const qualityScore = scoreQuality(qualityInput);
159
+
160
+ if (!meetsQualityThreshold(qualityInput)) {
161
+ return {
162
+ success: false,
163
+ quality_score: qualityScore,
164
+ reason: `Quality score ${qualityScore.toFixed(2)} below threshold`,
165
+ };
166
+ }
167
+
168
+ // If duplicate found, report merge (future: update existing observation)
169
+ if (duplicate) {
170
+ return {
171
+ success: true,
172
+ merged_into: duplicate.id,
173
+ quality_score: qualityScore,
174
+ reason: `Merged into existing observation #${duplicate.id}`,
175
+ };
176
+ }
177
+
178
+ // Insert observation
179
+ const obs = db.insertObservation({
180
+ session_id: input.session_id ?? null,
181
+ project_id: project.id,
182
+ type: input.type,
183
+ title,
184
+ narrative,
185
+ facts: factsJson,
186
+ concepts: conceptsJson,
187
+ files_read: filesReadJson,
188
+ files_modified: filesModifiedJson,
189
+ quality: qualityScore,
190
+ lifecycle: "active",
191
+ sensitivity,
192
+ user_id: config.user_id,
193
+ device_id: config.device_id,
194
+ agent: input.agent ?? "claude-code",
195
+ });
196
+
197
+ // Add to sync outbox
198
+ db.addToOutbox("observation", obs.id);
199
+
200
+ // Embed for local vector search (best-effort, non-blocking on failure)
201
+ if (db.vecAvailable) {
202
+ try {
203
+ const text = composeEmbeddingText(obs);
204
+ const embedding = await embedText(text);
205
+ if (embedding) {
206
+ db.vecInsert(obs.id, embedding);
207
+ }
208
+ } catch {
209
+ // Embedding failure is non-fatal — FTS5 still works
210
+ }
211
+ }
212
+
213
+ return {
214
+ success: true,
215
+ observation_id: obs.id,
216
+ quality_score: qualityScore,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Convert an absolute file path to a project-relative path.
222
+ * Already-relative paths are returned as-is.
223
+ * Paths outside the project root are returned as-is (no way to make relative).
224
+ */
225
+ function toRelativePath(filePath: string, projectRoot: string): string {
226
+ if (!isAbsolute(filePath)) return filePath;
227
+ const rel = relative(projectRoot, filePath);
228
+ // If relative() returns a path starting with "..", the file is outside project root
229
+ if (rel.startsWith("..")) return filePath;
230
+ return rel;
231
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mergeResults } from "./search.js";
3
+ import type { FtsMatchRow, VecMatchRow } from "../storage/sqlite.js";
4
+
5
+ describe("mergeResults (RRF)", () => {
6
+ test("merges FTS-only results", () => {
7
+ const fts: FtsMatchRow[] = [
8
+ { id: 1, rank: -5.0 },
9
+ { id: 2, rank: -3.0 },
10
+ { id: 3, rank: -1.0 },
11
+ ];
12
+ const vec: VecMatchRow[] = [];
13
+
14
+ const merged = mergeResults(fts, vec, 10);
15
+ expect(merged.length).toBe(3);
16
+ expect(merged[0]!.id).toBe(1); // rank 0 in FTS = highest RRF
17
+ expect(merged[1]!.id).toBe(2);
18
+ expect(merged[2]!.id).toBe(3);
19
+ });
20
+
21
+ test("merges vec-only results", () => {
22
+ const fts: FtsMatchRow[] = [];
23
+ const vec: VecMatchRow[] = [
24
+ { observation_id: 10, distance: 0.1 },
25
+ { observation_id: 20, distance: 0.5 },
26
+ ];
27
+
28
+ const merged = mergeResults(fts, vec, 10);
29
+ expect(merged.length).toBe(2);
30
+ expect(merged[0]!.id).toBe(10);
31
+ expect(merged[1]!.id).toBe(20);
32
+ });
33
+
34
+ test("boosts items appearing in both lists", () => {
35
+ const fts: FtsMatchRow[] = [
36
+ { id: 1, rank: -5.0 },
37
+ { id: 2, rank: -3.0 },
38
+ ];
39
+ const vec: VecMatchRow[] = [
40
+ { observation_id: 2, distance: 0.1 },
41
+ { observation_id: 3, distance: 0.2 },
42
+ ];
43
+
44
+ const merged = mergeResults(fts, vec, 10);
45
+ // ID 2 appears in both — should be boosted to top
46
+ expect(merged[0]!.id).toBe(2);
47
+ expect(merged[0]!.score).toBeGreaterThan(merged[1]!.score);
48
+ });
49
+
50
+ test("respects limit", () => {
51
+ const fts: FtsMatchRow[] = [
52
+ { id: 1, rank: -5 },
53
+ { id: 2, rank: -4 },
54
+ { id: 3, rank: -3 },
55
+ ];
56
+ const vec: VecMatchRow[] = [
57
+ { observation_id: 4, distance: 0.1 },
58
+ { observation_id: 5, distance: 0.2 },
59
+ ];
60
+
61
+ const merged = mergeResults(fts, vec, 3);
62
+ expect(merged.length).toBe(3);
63
+ });
64
+
65
+ test("handles empty inputs", () => {
66
+ const merged = mergeResults([], [], 10);
67
+ expect(merged.length).toBe(0);
68
+ });
69
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * search_observations MCP tool.
3
+ *
4
+ * Hybrid search: FTS5 (keyword) + sqlite-vec (semantic),
5
+ * merged via Reciprocal Rank Fusion. Falls back to FTS5-only
6
+ * when embeddings or sqlite-vec are unavailable.
7
+ */
8
+
9
+ import { detectProject } from "../storage/projects.js";
10
+ import type {
11
+ MemDatabase,
12
+ FtsMatchRow,
13
+ VecMatchRow,
14
+ } from "../storage/sqlite.js";
15
+ import { embedText } from "../embeddings/embedder.js";
16
+
17
+ export interface SearchInput {
18
+ query: string;
19
+ project_scoped?: boolean;
20
+ limit?: number;
21
+ cwd?: string;
22
+ }
23
+
24
+ export interface SearchResult {
25
+ observations: SearchResultEntry[];
26
+ total: number;
27
+ project?: string;
28
+ }
29
+
30
+ export interface SearchResultEntry {
31
+ id: number;
32
+ type: string;
33
+ title: string;
34
+ narrative: string | null;
35
+ facts: string | null;
36
+ concepts: string | null;
37
+ files_modified: string | null;
38
+ quality: number;
39
+ lifecycle: string;
40
+ created_at: string;
41
+ rank: number;
42
+ }
43
+
44
+ /**
45
+ * Hybrid search: FTS5 keywords + sqlite-vec semantic, merged via RRF.
46
+ */
47
+ export async function searchObservations(
48
+ db: MemDatabase,
49
+ input: SearchInput
50
+ ): Promise<SearchResult> {
51
+ const query = input.query.trim();
52
+ if (!query) {
53
+ return { observations: [], total: 0 };
54
+ }
55
+
56
+ const limit = input.limit ?? 10;
57
+ const projectScoped = input.project_scoped !== false;
58
+
59
+ let projectId: number | null = null;
60
+ let projectName: string | undefined;
61
+
62
+ if (projectScoped) {
63
+ const cwd = input.cwd ?? process.cwd();
64
+ const detected = detectProject(cwd);
65
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
66
+ if (project) {
67
+ projectId = project.id;
68
+ projectName = project.name;
69
+ }
70
+ }
71
+
72
+ // FTS5 keyword search
73
+ const safeQuery = sanitizeFtsQuery(query);
74
+ const ftsResults = safeQuery
75
+ ? db.searchFts(safeQuery, projectId, undefined, limit * 2)
76
+ : [];
77
+
78
+ // Vec semantic search (if available)
79
+ let vecResults: VecMatchRow[] = [];
80
+ const queryEmbedding = await embedText(query);
81
+ if (queryEmbedding && db.vecAvailable) {
82
+ vecResults = db.searchVec(
83
+ queryEmbedding,
84
+ projectId,
85
+ ["active", "aging", "pinned"],
86
+ limit * 2
87
+ );
88
+ }
89
+
90
+ // Merge via RRF if we have both, otherwise use whichever is available
91
+ const merged = mergeResults(ftsResults, vecResults, limit);
92
+
93
+ if (merged.length === 0) {
94
+ return { observations: [], total: 0, project: projectName };
95
+ }
96
+
97
+ const ids = merged.map((r) => r.id);
98
+ const scoreMap = new Map(merged.map((r) => [r.id, r.score]));
99
+ const observations = db.getObservationsByIds(ids);
100
+
101
+ // Filter out superseded observations
102
+ const active = observations.filter((obs) => obs.superseded_by === null);
103
+
104
+ // Apply lifecycle weighting
105
+ const entries: SearchResultEntry[] = active.map((obs) => {
106
+ const baseScore = scoreMap.get(obs.id) ?? 0;
107
+ const lifecycleWeight = obs.lifecycle === "aging" ? 0.7 : 1.0;
108
+
109
+ return {
110
+ id: obs.id,
111
+ type: obs.type,
112
+ title: obs.title,
113
+ narrative: obs.narrative,
114
+ facts: obs.facts,
115
+ concepts: obs.concepts,
116
+ files_modified: obs.files_modified,
117
+ quality: obs.quality,
118
+ lifecycle: obs.lifecycle,
119
+ created_at: obs.created_at,
120
+ rank: baseScore * lifecycleWeight,
121
+ };
122
+ });
123
+
124
+ // Sort by score (higher = better match)
125
+ entries.sort((a, b) => b.rank - a.rank);
126
+
127
+ return {
128
+ observations: entries,
129
+ total: entries.length,
130
+ project: projectName,
131
+ };
132
+ }
133
+
134
+ // --- Reciprocal Rank Fusion ---
135
+
136
+ const RRF_K = 60;
137
+
138
+ interface ScoredId {
139
+ id: number;
140
+ score: number;
141
+ }
142
+
143
+ /**
144
+ * Merge FTS5 and vec results using Reciprocal Rank Fusion.
145
+ * RRF is rank-based, so no score normalization needed.
146
+ * Items appearing in both lists get boosted.
147
+ */
148
+ export function mergeResults(
149
+ ftsResults: FtsMatchRow[],
150
+ vecResults: VecMatchRow[],
151
+ limit: number
152
+ ): ScoredId[] {
153
+ const scores = new Map<number, number>();
154
+
155
+ // FTS results sorted by rank (more negative = better match)
156
+ for (let rank = 0; rank < ftsResults.length; rank++) {
157
+ const id = ftsResults[rank]!.id;
158
+ scores.set(id, (scores.get(id) ?? 0) + 1 / (RRF_K + rank + 1));
159
+ }
160
+
161
+ // Vec results sorted by distance (lower = closer)
162
+ for (let rank = 0; rank < vecResults.length; rank++) {
163
+ const id = vecResults[rank]!.observation_id;
164
+ scores.set(id, (scores.get(id) ?? 0) + 1 / (RRF_K + rank + 1));
165
+ }
166
+
167
+ return Array.from(scores.entries())
168
+ .map(([id, score]) => ({ id, score }))
169
+ .sort((a, b) => b.score - a.score)
170
+ .slice(0, limit);
171
+ }
172
+
173
+ /**
174
+ * Sanitize a query for FTS5.
175
+ */
176
+ function sanitizeFtsQuery(query: string): string {
177
+ let safe = query.replace(/[{}()[\]^~*:]/g, " ");
178
+ safe = safe.replace(/\s+/g, " ").trim();
179
+ if (!safe) return "";
180
+ return safe;
181
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * get_timeline MCP tool.
3
+ *
4
+ * Returns chronological observations around an anchor point,
5
+ * providing temporal context for understanding sequences of events.
6
+ */
7
+
8
+ import { detectProject } from "../storage/projects.js";
9
+ import type { MemDatabase, ObservationRow } from "../storage/sqlite.js";
10
+
11
+ export interface TimelineInput {
12
+ anchor_id: number;
13
+ depth_before?: number;
14
+ depth_after?: number;
15
+ project_scoped?: boolean;
16
+ cwd?: string;
17
+ }
18
+
19
+ export interface TimelineResult {
20
+ observations: ObservationRow[];
21
+ anchor_index: number;
22
+ project?: string;
23
+ }
24
+
25
+ /**
26
+ * Get a timeline of observations around an anchor.
27
+ */
28
+ export function getTimeline(
29
+ db: MemDatabase,
30
+ input: TimelineInput
31
+ ): TimelineResult {
32
+ const depthBefore = input.depth_before ?? 3;
33
+ const depthAfter = input.depth_after ?? 3;
34
+ const projectScoped = input.project_scoped !== false;
35
+
36
+ let projectId: number | null = null;
37
+ let projectName: string | undefined;
38
+
39
+ if (projectScoped) {
40
+ const cwd = input.cwd ?? process.cwd();
41
+ const detected = detectProject(cwd);
42
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
43
+ if (project) {
44
+ projectId = project.id;
45
+ projectName = project.name;
46
+ }
47
+ }
48
+
49
+ const observations = db.getTimeline(
50
+ input.anchor_id,
51
+ projectId,
52
+ depthBefore,
53
+ depthAfter
54
+ );
55
+
56
+ // Find the anchor's position in the result
57
+ const anchorIndex = observations.findIndex((o) => o.id === input.anchor_id);
58
+
59
+ return {
60
+ observations,
61
+ anchor_index: anchorIndex >= 0 ? anchorIndex : 0,
62
+ project: projectName,
63
+ };
64
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "types": ["bun-types"],
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true,
16
+ "outDir": "dist",
17
+ "rootDir": "src",
18
+ "lib": ["ESNext"]
19
+ },
20
+ "include": ["src/**/*.ts"],
21
+ "exclude": ["node_modules", "dist"]
22
+ }