clanka 0.1.22 → 0.2.1

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.
@@ -10,7 +10,7 @@ import { pipe } from "effect/Function"
10
10
  import * as EmbeddingModel from "effect/unstable/ai/EmbeddingModel"
11
11
  import * as RequestResolver from "effect/RequestResolver"
12
12
  import * as Option from "effect/Option"
13
- import type * as Path from "effect/Path"
13
+ import * as Path from "effect/Path"
14
14
  import * as ServiceMap from "effect/ServiceMap"
15
15
  import * as Fiber from "effect/Fiber"
16
16
  import * as Duration from "effect/Duration"
@@ -23,6 +23,13 @@ import type * as ChildProcessSpawner from "effect/unstable/process/ChildProcessS
23
23
  import type * as FileSystem from "effect/FileSystem"
24
24
  import * as Console from "effect/Console"
25
25
 
26
+ const normalizePath = (path: string) => path.replace(/\\/g, "/")
27
+
28
+ const chunkConfig = {
29
+ chunkSize: 20,
30
+ chunkOverlap: 5,
31
+ } as const
32
+
26
33
  /**
27
34
  * @since 1.0.0
28
35
  * @category Services
@@ -34,7 +41,8 @@ export class SemanticSearch extends ServiceMap.Service<
34
41
  readonly query: string
35
42
  readonly limit: number
36
43
  }): Effect.Effect<string>
37
- readonly reindex: Effect.Effect<void>
44
+ updateFile(path: string): Effect.Effect<void>
45
+ removeFile(path: string): Effect.Effect<void>
38
46
  }
39
47
  >()("clanka/SemanticSearch/SemanticSearch") {}
40
48
 
@@ -65,73 +73,106 @@ export const layer = (options: {
65
73
  const chunker = yield* CodeChunker.CodeChunker
66
74
  const repo = yield* ChunkRepo.ChunkRepo
67
75
  const embeddings = yield* EmbeddingModel.EmbeddingModel
76
+ const pathService = yield* Path.Path
77
+ const root = pathService.resolve(options.directory)
68
78
  const resolver = embeddings.resolver.pipe(
69
79
  RequestResolver.setDelay(
70
80
  options.embeddingBatchSize ?? Duration.millis(50),
71
81
  ),
72
82
  RequestResolver.batchN(options.embeddingBatchSize ?? 500),
73
83
  )
84
+ const concurrency = options.concurrency ?? 2000
74
85
  const indexHandle = yield* FiberHandle.make()
75
86
  const console = yield* Console.Console
76
87
 
88
+ const resolveIndexedPath = (path: string): Option.Option<string> => {
89
+ const absolutePath = pathService.resolve(root, path)
90
+ const relativePath = normalizePath(
91
+ pathService.relative(root, absolutePath),
92
+ )
93
+ if (
94
+ relativePath.length === 0 ||
95
+ relativePath === ".." ||
96
+ relativePath.startsWith("../")
97
+ ) {
98
+ return Option.none()
99
+ }
100
+ return Option.some(relativePath)
101
+ }
102
+
103
+ const processChunk = Effect.fnUntraced(
104
+ function* (options: {
105
+ readonly chunk: CodeChunker.CodeChunk
106
+ readonly syncId: ChunkRepo.SyncId
107
+ readonly checkExisting: boolean
108
+ }) {
109
+ if (options.checkExisting) {
110
+ const id = yield* repo.exists({
111
+ path: options.chunk.path,
112
+ startLine: options.chunk.startLine,
113
+ hash: options.chunk.contentHash,
114
+ })
115
+ if (Option.isSome(id)) {
116
+ yield* repo.setSyncId(id.value, options.syncId)
117
+ return
118
+ }
119
+ }
120
+
121
+ const result = yield* Effect.request(
122
+ new EmbeddingModel.EmbeddingRequest({
123
+ input: `File: ${options.chunk.path}
124
+ Lines: ${options.chunk.startLine}-${options.chunk.endLine}
125
+
126
+ ${options.chunk.content}`,
127
+ }),
128
+ resolver,
129
+ )
130
+ const vector = new Float32Array(result.vector)
131
+ yield* repo.insert(
132
+ ChunkRepo.Chunk.insert.makeUnsafe({
133
+ path: options.chunk.path,
134
+ startLine: options.chunk.startLine,
135
+ endLine: options.chunk.endLine,
136
+ hash: options.chunk.contentHash,
137
+ content: options.chunk.content,
138
+ vector,
139
+ syncId: options.syncId,
140
+ }),
141
+ )
142
+ },
143
+ Effect.ignore({
144
+ log: "Warn",
145
+ message: "Failed to process chunk for embedding",
146
+ }),
147
+ (effect, options) =>
148
+ Effect.annotateLogs(effect, {
149
+ chunk: `${options.chunk.path}/${options.chunk.startLine}`,
150
+ }),
151
+ )
152
+
77
153
  const index = Effect.gen(function* () {
78
154
  const syncId = ChunkRepo.SyncId.makeUnsafe(crypto.randomUUID())
79
155
  yield* Effect.logInfo("Starting SemanticSearch index")
80
156
 
81
157
  yield* pipe(
82
158
  chunker.chunkCodebase({
83
- root: options.directory,
84
- chunkSize: 20,
85
- chunkOverlap: 5,
159
+ root,
160
+ ...chunkConfig,
86
161
  }),
87
162
  Stream.tap(
88
- Effect.fnUntraced(
89
- function* (chunk) {
90
- const id = yield* repo.exists({
91
- path: chunk.path,
92
- startLine: chunk.startLine,
93
- hash: chunk.contentHash,
94
- })
95
- if (Option.isSome(id)) {
96
- yield* repo.setSyncId(id.value, syncId)
97
- return
98
- }
99
- const result = yield* Effect.request(
100
- new EmbeddingModel.EmbeddingRequest({
101
- input: `File: ${chunk.path}
102
- Lines: ${chunk.startLine}-${chunk.endLine}
103
-
104
- ${chunk.content}`,
105
- }),
106
- resolver,
107
- )
108
- const vector = new Float32Array(result.vector)
109
- yield* repo.insert(
110
- ChunkRepo.Chunk.insert.makeUnsafe({
111
- path: chunk.path,
112
- startLine: chunk.startLine,
113
- endLine: chunk.endLine,
114
- hash: chunk.contentHash,
115
- content: chunk.content,
116
- vector,
117
- syncId,
118
- }),
119
- )
120
- },
121
- Effect.ignore({
122
- log: "Warn",
123
- message: "Failed to process chunk for embedding",
163
+ (chunk) =>
164
+ processChunk({
165
+ chunk,
166
+ syncId,
167
+ checkExisting: true,
124
168
  }),
125
- (effect, chunk) =>
126
- Effect.annotateLogs(effect, {
127
- chunk: `${chunk.path}/${chunk.startLine}`,
128
- }),
129
- ),
130
- { concurrency: options.concurrency ?? 2000 },
169
+ { concurrency },
131
170
  ),
132
171
  Stream.runDrain,
133
172
  )
134
173
 
174
+ yield* repo.deleteForSyncId(syncId)
175
+
135
176
  yield* Effect.logInfo("Finished SemanticSearch index")
136
177
  }).pipe(
137
178
  Effect.withSpan("SemanticSearch.index"),
@@ -161,7 +202,48 @@ ${chunk.content}`,
161
202
  })
162
203
  return results.map((r) => r.format()).join("\n\n")
163
204
  }, Effect.orDie),
164
- reindex: Effect.asVoid(runIndex),
205
+ updateFile: Effect.fn("SemanticSearch.updateFile")(function* (path) {
206
+ yield* Fiber.join(initialIndex)
207
+ const indexedPath = resolveIndexedPath(path)
208
+ if (Option.isNone(indexedPath)) {
209
+ return
210
+ }
211
+
212
+ yield* repo.deleteByPath(indexedPath.value)
213
+
214
+ const chunks = yield* chunker.chunkFile({
215
+ root,
216
+ path: indexedPath.value,
217
+ ...chunkConfig,
218
+ })
219
+ if (chunks.length === 0) {
220
+ return
221
+ }
222
+
223
+ const syncId = ChunkRepo.SyncId.makeUnsafe(crypto.randomUUID())
224
+
225
+ yield* pipe(
226
+ Stream.fromArray(chunks),
227
+ Stream.tap(
228
+ (chunk) =>
229
+ processChunk({
230
+ chunk,
231
+ syncId,
232
+ checkExisting: false,
233
+ }),
234
+ { concurrency },
235
+ ),
236
+ Stream.runDrain,
237
+ )
238
+ }, Effect.orDie),
239
+ removeFile: Effect.fn("SemanticSearch.removeFile")(function* (path) {
240
+ yield* Fiber.join(initialIndex)
241
+ const indexedPath = resolveIndexedPath(path)
242
+ if (Option.isNone(indexedPath)) {
243
+ return
244
+ }
245
+ yield* repo.deleteByPath(indexedPath.value)
246
+ }, Effect.orDie),
165
247
  })
166
248
  }),
167
249
  ).pipe(
@@ -177,13 +259,26 @@ ${chunk.content}`,
177
259
  * @since 1.0.0
178
260
  * @category Utils
179
261
  */
180
- export const maybeReindex: Effect.Effect<void> = Effect.serviceOption(
181
- SemanticSearch,
182
- ).pipe(
183
- Effect.flatMap(
184
- Option.match({
185
- onNone: () => Effect.void,
186
- onSome: (service) => service.reindex,
187
- }),
188
- ),
189
- )
262
+ export const maybeUpdateFile = (path: string): Effect.Effect<void> =>
263
+ Effect.serviceOption(SemanticSearch).pipe(
264
+ Effect.flatMap(
265
+ Option.match({
266
+ onNone: () => Effect.void,
267
+ onSome: (service) => service.updateFile(path),
268
+ }),
269
+ ),
270
+ )
271
+
272
+ /**
273
+ * @since 1.0.0
274
+ * @category Utils
275
+ */
276
+ export const maybeRemoveFile = (path: string): Effect.Effect<void> =>
277
+ Effect.serviceOption(SemanticSearch).pipe(
278
+ Effect.flatMap(
279
+ Option.match({
280
+ onNone: () => Effect.void,
281
+ onSome: (service) => service.removeFile(path),
282
+ }),
283
+ ),
284
+ )