cli: index/search/status/mcp-probe
This commit is contained in:
@@ -1,3 +1,14 @@
|
|||||||
plugins {
|
plugins {
|
||||||
kotlin("jvm")
|
kotlin("jvm")
|
||||||
|
application
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set("memo.cli.MainKt")
|
||||||
|
applicationName = "memo"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":memo-core"))
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
package memo.cli
|
||||||
|
|
||||||
|
import memo.core.Db
|
||||||
|
import memo.core.Embedder
|
||||||
|
import memo.core.Hit
|
||||||
|
import memo.core.Indexer
|
||||||
|
import memo.core.RefreshHook
|
||||||
|
import memo.core.SearchMode
|
||||||
|
import memo.core.Searcher
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
sealed interface Cmd
|
||||||
|
data class IndexCmd(val path: String) : Cmd
|
||||||
|
data class SearchCmd(
|
||||||
|
val path: String,
|
||||||
|
val query: String,
|
||||||
|
val k: Int,
|
||||||
|
val mode: SearchMode,
|
||||||
|
val json: Boolean,
|
||||||
|
) : Cmd
|
||||||
|
data class StatusCmd(val path: String) : Cmd
|
||||||
|
data class McpProbeCmd(val tool: String, val argsJson: String) : Cmd
|
||||||
|
data object HelpCmd : Cmd
|
||||||
|
|
||||||
|
fun parseArgs(args: Array<String>): Cmd {
|
||||||
|
if (args.isEmpty()) return HelpCmd
|
||||||
|
return when (val cmd = args[0]) {
|
||||||
|
"index" -> {
|
||||||
|
val path = args.getOrNull(1) ?: return HelpCmd
|
||||||
|
IndexCmd(path)
|
||||||
|
}
|
||||||
|
"search" -> parseSearch(args.drop(1))
|
||||||
|
"status" -> {
|
||||||
|
val path = args.getOrNull(1) ?: return HelpCmd
|
||||||
|
StatusCmd(path)
|
||||||
|
}
|
||||||
|
"mcp-probe" -> parseMcpProbe(args.drop(1))
|
||||||
|
"--help", "-h" -> HelpCmd
|
||||||
|
else -> HelpCmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSearch(rest: List<String>): Cmd {
|
||||||
|
if (rest.size < 2) return HelpCmd
|
||||||
|
val path = rest[0]
|
||||||
|
val query = rest[1]
|
||||||
|
var k = 8
|
||||||
|
var mode = SearchMode.HYBRID
|
||||||
|
var json = false
|
||||||
|
var i = 2
|
||||||
|
while (i < rest.size) {
|
||||||
|
when (val a = rest[i]) {
|
||||||
|
"--k" -> {
|
||||||
|
val v = rest.getOrNull(i + 1) ?: return HelpCmd
|
||||||
|
val n = v.toIntOrNull() ?: return HelpCmd
|
||||||
|
k = n
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
"--mode" -> {
|
||||||
|
val v = rest.getOrNull(i + 1) ?: return HelpCmd
|
||||||
|
mode = when (v) {
|
||||||
|
"hybrid" -> SearchMode.HYBRID
|
||||||
|
"lex" -> SearchMode.LEX
|
||||||
|
"vec" -> SearchMode.VEC
|
||||||
|
else -> return HelpCmd
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
"--json" -> {
|
||||||
|
json = true
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
else -> return HelpCmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SearchCmd(path, query, k, mode, json)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseMcpProbe(rest: List<String>): Cmd {
|
||||||
|
var tool: String? = null
|
||||||
|
var argsJson = "{}"
|
||||||
|
var i = 0
|
||||||
|
while (i < rest.size) {
|
||||||
|
when (rest[i]) {
|
||||||
|
"--tool" -> {
|
||||||
|
tool = rest.getOrNull(i + 1) ?: return HelpCmd
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
"--args" -> {
|
||||||
|
argsJson = rest.getOrNull(i + 1) ?: return HelpCmd
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
else -> return HelpCmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val t = tool ?: return HelpCmd
|
||||||
|
return McpProbeCmd(t, argsJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
when (val cmd = parseArgs(args)) {
|
||||||
|
is IndexCmd -> runIndex(cmd)
|
||||||
|
is SearchCmd -> runSearch(cmd)
|
||||||
|
is StatusCmd -> runStatus(cmd)
|
||||||
|
is McpProbeCmd -> runMcpProbe(cmd)
|
||||||
|
HelpCmd -> printHelp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun printHelp() {
|
||||||
|
println(
|
||||||
|
"""
|
||||||
|
usage:
|
||||||
|
memo index <path>
|
||||||
|
memo search <path> <query> [--k N] [--mode hybrid|lex|vec] [--json]
|
||||||
|
memo status <path>
|
||||||
|
memo mcp-probe --tool <name> [--args <json>]
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun modelPaths(): Pair<String, String> {
|
||||||
|
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
||||||
|
return "$modelDir/text_model_int8.onnx" to "$modelDir/tokenizer.model"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runIndex(cmd: IndexCmd) {
|
||||||
|
val raw = File(cmd.path)
|
||||||
|
val base = if (raw.name == ".memo") raw.parentFile ?: raw else raw
|
||||||
|
val collections = discoverCollections(base)
|
||||||
|
if (collections.isEmpty()) {
|
||||||
|
System.err.println("коллекции не найдены в ${base.absolutePath}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val (modelPath, tokenizerPath) = modelPaths()
|
||||||
|
var totalUpdated = 0
|
||||||
|
for (coll in collections) {
|
||||||
|
val memoDir = File(coll, ".memo")
|
||||||
|
memoDir.mkdirs()
|
||||||
|
val dbPath = File(memoDir, "index.db").absolutePath
|
||||||
|
val totalFiles = coll.walkTopDown()
|
||||||
|
.maxDepth(8)
|
||||||
|
.count { it.isFile && it.extension == "md" }
|
||||||
|
val db = Db(dbPath)
|
||||||
|
try {
|
||||||
|
db.init()
|
||||||
|
val embedder = Embedder(modelPath, tokenizerPath)
|
||||||
|
try {
|
||||||
|
val updated = Indexer(db, embedder).indexTree(coll)
|
||||||
|
println("индексировано: $updated обновлено, $totalFiles файлов всего")
|
||||||
|
totalUpdated += updated
|
||||||
|
} finally {
|
||||||
|
embedder.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println("итого: $totalUpdated обновлено в ${collections.size} коллекциях")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun discoverCollections(base: File): List<File> {
|
||||||
|
if (!base.isDirectory) return emptyList()
|
||||||
|
val candidates = LinkedHashSet<File>()
|
||||||
|
candidates.add(base)
|
||||||
|
val q = ArrayDeque<Pair<File, Int>>()
|
||||||
|
q.addLast(base to 0)
|
||||||
|
while (q.isNotEmpty()) {
|
||||||
|
val (d, depth) = q.removeFirst()
|
||||||
|
if (depth >= 2) continue
|
||||||
|
val children = d.listFiles() ?: continue
|
||||||
|
for (c in children) {
|
||||||
|
if (c.isDirectory && !c.name.startsWith(".")) {
|
||||||
|
candidates.add(c)
|
||||||
|
q.addLast(c to depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates.filter { d ->
|
||||||
|
d.walkTopDown()
|
||||||
|
.maxDepth(8)
|
||||||
|
.any { it.isFile && it.extension == "md" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runSearch(cmd: SearchCmd) {
|
||||||
|
val targets = resolveSearchTargets(File(cmd.path))
|
||||||
|
if (targets.isEmpty()) {
|
||||||
|
if (cmd.json) println("[]")
|
||||||
|
else println("коллекции не найдены")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val (modelPath, tokenizerPath) = modelPaths()
|
||||||
|
val allHits = ArrayList<Hit>()
|
||||||
|
for (coll in targets) {
|
||||||
|
val dbPath = File(coll, ".memo/index.db")
|
||||||
|
if (!dbPath.exists()) continue
|
||||||
|
runCatching {
|
||||||
|
val db = Db(dbPath.absolutePath)
|
||||||
|
try {
|
||||||
|
val embedder = Embedder(modelPath, tokenizerPath)
|
||||||
|
try {
|
||||||
|
val refresh = RefreshHook { root ->
|
||||||
|
val innerDbPath = File(root, ".memo/index.db")
|
||||||
|
if (innerDbPath.exists()) {
|
||||||
|
val innerDb = Db(innerDbPath.absolutePath)
|
||||||
|
try {
|
||||||
|
Indexer(innerDb, embedder).indexTree(root)
|
||||||
|
} finally {
|
||||||
|
innerDb.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val hits = Searcher(db, embedder, refresh)
|
||||||
|
.search(coll, cmd.query, cmd.k, cmd.mode)
|
||||||
|
allHits.addAll(hits)
|
||||||
|
} finally {
|
||||||
|
embedder.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val merged = allHits.sortedByDescending { it.score }.take(cmd.k)
|
||||||
|
if (cmd.json) {
|
||||||
|
printJsonHits(merged)
|
||||||
|
} else {
|
||||||
|
for (h in merged) {
|
||||||
|
val text = if (h.text.length > 300) h.text.substring(0, 300) else h.text
|
||||||
|
println(String.format("%.3f %s:%d %s", h.score, h.path, h.line, h.heading))
|
||||||
|
for (line in text.lines()) {
|
||||||
|
println(" $line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveSearchTargets(path: File): List<File> {
|
||||||
|
if (path.isFile && path.extension == "md") {
|
||||||
|
return listOfNotNull(path.parentFile)
|
||||||
|
}
|
||||||
|
if (File(path, ".memo/index.db").exists()) {
|
||||||
|
return listOf(path)
|
||||||
|
}
|
||||||
|
val subs = path.listFiles()
|
||||||
|
?.filter { it.isDirectory && File(it, ".memo/index.db").exists() }
|
||||||
|
?: emptyList()
|
||||||
|
return subs
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runStatus(cmd: StatusCmd) {
|
||||||
|
val base = File(cmd.path)
|
||||||
|
val collections = if (File(base, ".memo/index.db").exists()) {
|
||||||
|
listOf(base)
|
||||||
|
} else {
|
||||||
|
discoverCollections(base)
|
||||||
|
}
|
||||||
|
if (collections.isEmpty()) {
|
||||||
|
println("коллекции не найдены")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (coll in collections) {
|
||||||
|
val dbPath = File(coll, ".memo/index.db")
|
||||||
|
if (!dbPath.exists()) {
|
||||||
|
println("${coll.name}: нет индекса")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val db = Db(dbPath.absolutePath)
|
||||||
|
try {
|
||||||
|
val fileCount = countInt(db, "SELECT COUNT(*) FROM files")
|
||||||
|
val chunkCount = countInt(db, "SELECT COUNT(*) FROM chunks")
|
||||||
|
val indexedAt = maxDouble(db, "SELECT indexed_at FROM files WHERE indexed_at IS NOT NULL")
|
||||||
|
val date = if (indexedAt != null) {
|
||||||
|
java.time.Instant.ofEpochSecond(indexedAt.toLong()).toString()
|
||||||
|
} else {
|
||||||
|
"—"
|
||||||
|
}
|
||||||
|
println("$coll: файлов $fileCount, чанков $chunkCount, индекс $date")
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun countInt(db: Db, sql: String): Int {
|
||||||
|
val stmt = db.conn.prepare(sql)
|
||||||
|
return try {
|
||||||
|
val rs = stmt.executeQuery()
|
||||||
|
try {
|
||||||
|
if (rs.next()) rs.getInt(0) ?: 0 else 0
|
||||||
|
} finally {
|
||||||
|
rs.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stmt.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maxDouble(db: Db, sql: String): Double? {
|
||||||
|
val stmt = db.conn.prepare(sql)
|
||||||
|
return try {
|
||||||
|
val rs = stmt.executeQuery()
|
||||||
|
try {
|
||||||
|
var best: Double? = null
|
||||||
|
while (rs.next()) {
|
||||||
|
val v = rs.getDouble(0) ?: continue
|
||||||
|
if (best == null || v > best) best = v
|
||||||
|
}
|
||||||
|
best
|
||||||
|
} finally {
|
||||||
|
rs.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stmt.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runMcpProbe(cmd: McpProbeCmd) {
|
||||||
|
when (cmd.tool) {
|
||||||
|
"memo_reindex" -> printMcpError("not implemented yet")
|
||||||
|
"memo_search" -> {
|
||||||
|
val args = parseJsonArgs(cmd.argsJson)
|
||||||
|
val path = (args["path"] as? String) ?: ""
|
||||||
|
val query = (args["query"] as? String) ?: ""
|
||||||
|
val k = (args["k"] as? Number)?.toInt() ?: 8
|
||||||
|
val mode = when (args["mode"] as? String) {
|
||||||
|
"lex" -> SearchMode.LEX
|
||||||
|
"vec" -> SearchMode.VEC
|
||||||
|
else -> SearchMode.HYBRID
|
||||||
|
}
|
||||||
|
val jsonOutput = (args["json"] as? Boolean) ?: true
|
||||||
|
val hits = performSearch(path, query, k, mode)
|
||||||
|
val text = if (jsonOutput) formatHitsJson(hits) else formatHitsHuman(hits)
|
||||||
|
printMcpOk(text)
|
||||||
|
}
|
||||||
|
"memo_status" -> {
|
||||||
|
val args = parseJsonArgs(cmd.argsJson)
|
||||||
|
val path = (args["path"] as? String) ?: ""
|
||||||
|
val text = formatStatusInner(path)
|
||||||
|
printMcpOk(text)
|
||||||
|
}
|
||||||
|
else -> printMcpError("unknown tool: ${cmd.tool}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performSearch(path: String, query: String, k: Int, mode: SearchMode): List<Hit> {
|
||||||
|
val targets = resolveSearchTargets(File(path))
|
||||||
|
if (targets.isEmpty()) return emptyList()
|
||||||
|
val (modelPath, tokenizerPath) = modelPaths()
|
||||||
|
val allHits = ArrayList<Hit>()
|
||||||
|
for (coll in targets) {
|
||||||
|
val dbPath = File(coll, ".memo/index.db")
|
||||||
|
if (!dbPath.exists()) continue
|
||||||
|
runCatching {
|
||||||
|
val db = Db(dbPath.absolutePath)
|
||||||
|
try {
|
||||||
|
val embedder = Embedder(modelPath, tokenizerPath)
|
||||||
|
try {
|
||||||
|
val refresh = RefreshHook { root ->
|
||||||
|
val innerDbPath = File(root, ".memo/index.db")
|
||||||
|
if (innerDbPath.exists()) {
|
||||||
|
val innerDb = Db(innerDbPath.absolutePath)
|
||||||
|
try {
|
||||||
|
Indexer(innerDb, embedder).indexTree(root)
|
||||||
|
} finally {
|
||||||
|
innerDb.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val hits = Searcher(db, embedder, refresh)
|
||||||
|
.search(coll, query, k, mode)
|
||||||
|
allHits.addAll(hits)
|
||||||
|
} finally {
|
||||||
|
embedder.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allHits.sortedByDescending { it.score }.take(k)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatStatusInner(path: String): String {
|
||||||
|
val base = File(path)
|
||||||
|
val collections = if (File(base, ".memo/index.db").exists()) {
|
||||||
|
listOf(base)
|
||||||
|
} else {
|
||||||
|
discoverCollections(base)
|
||||||
|
}
|
||||||
|
if (collections.isEmpty()) return "коллекции не найдены"
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (coll in collections) {
|
||||||
|
val dbPath = File(coll, ".memo/index.db")
|
||||||
|
if (!dbPath.exists()) {
|
||||||
|
sb.append("${coll.name}: нет индекса\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val db = Db(dbPath.absolutePath)
|
||||||
|
try {
|
||||||
|
val fileCount = countInt(db, "SELECT COUNT(*) FROM files")
|
||||||
|
val chunkCount = countInt(db, "SELECT COUNT(*) FROM chunks")
|
||||||
|
val indexedAt = maxDouble(db, "SELECT indexed_at FROM files WHERE indexed_at IS NOT NULL")
|
||||||
|
val date = if (indexedAt != null) {
|
||||||
|
java.time.Instant.ofEpochSecond(indexedAt.toLong()).toString()
|
||||||
|
} else {
|
||||||
|
"—"
|
||||||
|
}
|
||||||
|
sb.append("$coll: файлов $fileCount, чанков $chunkCount, индекс $date\n")
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString().trimEnd('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun printJsonHits(hits: List<Hit>) {
|
||||||
|
println(formatHitsJson(hits))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatHitsJson(hits: List<Hit>): String {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
sb.append('[')
|
||||||
|
for ((i, h) in hits.withIndex()) {
|
||||||
|
if (i > 0) sb.append(',')
|
||||||
|
sb.append('{')
|
||||||
|
sb.append("\"path\":").append(jsonStr(h.path)).append(',')
|
||||||
|
sb.append("\"line\":").append(h.line).append(',')
|
||||||
|
sb.append("\"heading\":").append(jsonStr(h.heading)).append(',')
|
||||||
|
sb.append("\"score\":").append("%.6f".format(h.score)).append(',')
|
||||||
|
sb.append("\"text\":").append(jsonStr(h.text))
|
||||||
|
sb.append('}')
|
||||||
|
}
|
||||||
|
sb.append(']')
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatHitsHuman(hits: List<Hit>): String {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (h in hits) {
|
||||||
|
val text = if (h.text.length > 300) h.text.substring(0, 300) else h.text
|
||||||
|
sb.append(String.format("%.3f %s:%d %s", h.score, h.path, h.line, h.heading))
|
||||||
|
sb.append('\n')
|
||||||
|
for (line in text.lines()) {
|
||||||
|
sb.append(" ").append(line).append('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString().trimEnd('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonStr(s: String): String {
|
||||||
|
val sb = StringBuilder(s.length + 2)
|
||||||
|
sb.append('"')
|
||||||
|
for (c in s) {
|
||||||
|
when (c) {
|
||||||
|
'"' -> sb.append("\\\"")
|
||||||
|
'\\' -> sb.append("\\\\")
|
||||||
|
'\n' -> sb.append("\\n")
|
||||||
|
'\r' -> sb.append("\\r")
|
||||||
|
'\t' -> sb.append("\\t")
|
||||||
|
'\b' -> sb.append("\\b")
|
||||||
|
'\u000C' -> sb.append("\\f")
|
||||||
|
else -> if (c.code < 0x20) {
|
||||||
|
sb.append("\\u%04x".format(c.code))
|
||||||
|
} else {
|
||||||
|
sb.append(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append('"')
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun printMcpOk(text: String) {
|
||||||
|
println("""{"content":[{"type":"text","text":${jsonStr(text)}}],"isError":false}""")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun printMcpError(text: String) {
|
||||||
|
println("""{"content":[{"type":"text","text":${jsonStr(text)}}],"isError":true}""")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseJsonArgs(json: String): Map<String, Any?> {
|
||||||
|
val map = HashMap<String, Any?>()
|
||||||
|
val trimmed = json.trim()
|
||||||
|
if (trimmed.isEmpty() || trimmed == "{}") return map
|
||||||
|
val inner = trimmed.trim().removePrefix("{").removeSuffix("}")
|
||||||
|
if (inner.isBlank()) return map
|
||||||
|
var i = 0
|
||||||
|
while (i < inner.length) {
|
||||||
|
while (i < inner.length && inner[i].isWhitespace()) i++
|
||||||
|
if (i >= inner.length) break
|
||||||
|
if (inner[i] != '"') return map
|
||||||
|
val keyEnd = readJsonString(inner, i)
|
||||||
|
val key = unescapeJson(inner.substring(i + 1, keyEnd))
|
||||||
|
i = keyEnd + 1
|
||||||
|
while (i < inner.length && inner[i].isWhitespace()) i++
|
||||||
|
if (i >= inner.length || inner[i] != ':') return map
|
||||||
|
i++
|
||||||
|
while (i < inner.length && inner[i].isWhitespace()) i++
|
||||||
|
if (i >= inner.length) return map
|
||||||
|
val (value, next) = readJsonValue(inner, i)
|
||||||
|
map[key] = value
|
||||||
|
i = next
|
||||||
|
while (i < inner.length && inner[i].isWhitespace()) i++
|
||||||
|
if (i < inner.length && inner[i] == ',') i++
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readJsonString(s: String, start: Int): Int {
|
||||||
|
var i = start + 1
|
||||||
|
while (i < s.length) {
|
||||||
|
if (s[i] == '\\') {
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (s[i] == '"') return i
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return s.length
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readJsonValue(s: String, start: Int): Pair<Any?, Int> {
|
||||||
|
val c = s[start]
|
||||||
|
return when {
|
||||||
|
c == '"' -> {
|
||||||
|
val end = readJsonString(s, start)
|
||||||
|
unescapeJson(s.substring(start + 1, end)) to (end + 1)
|
||||||
|
}
|
||||||
|
c == 't' && s.regionMatches(start, "true", 0, 4) -> true to (start + 4)
|
||||||
|
c == 'f' && s.regionMatches(start, "false", 0, 5) -> false to (start + 5)
|
||||||
|
c == 'n' && s.regionMatches(start, "null", 0, 4) -> null to (start + 4)
|
||||||
|
c == '-' || c.isDigit() -> {
|
||||||
|
var i = start
|
||||||
|
while (i < s.length && (s[i].isDigit() || s[i] == '-' || s[i] == '+' || s[i] == '.' || s[i] == 'e' || s[i] == 'E')) i++
|
||||||
|
val raw = s.substring(start, i)
|
||||||
|
val num = raw.toDoubleOrNull() ?: raw
|
||||||
|
num to i
|
||||||
|
}
|
||||||
|
else -> null to (start + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unescapeJson(s: String): String {
|
||||||
|
val sb = StringBuilder(s.length)
|
||||||
|
var i = 0
|
||||||
|
while (i < s.length) {
|
||||||
|
val c = s[i]
|
||||||
|
if (c == '\\' && i + 1 < s.length) {
|
||||||
|
when (s[i + 1]) {
|
||||||
|
'"' -> sb.append('"')
|
||||||
|
'\\' -> sb.append('\\')
|
||||||
|
'/' -> sb.append('/')
|
||||||
|
'n' -> sb.append('\n')
|
||||||
|
'r' -> sb.append('\r')
|
||||||
|
't' -> sb.append('\t')
|
||||||
|
'b' -> sb.append('\b')
|
||||||
|
'f' -> sb.append('\u000C')
|
||||||
|
'u' -> {
|
||||||
|
if (i + 5 < s.length) {
|
||||||
|
val hex = s.substring(i + 2, i + 6)
|
||||||
|
sb.append(hex.toInt(16).toChar())
|
||||||
|
i += 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
sb.append(c)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package memo.cli
|
||||||
|
|
||||||
|
import memo.core.SearchMode
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class CliArgsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun indexCommandParsed() {
|
||||||
|
val cmd = parseArgs(arrayOf("index", "/tmp/x"))
|
||||||
|
assertTrue(cmd is IndexCmd, "ожидался IndexCmd, получено $cmd")
|
||||||
|
assertEquals("/tmp/x", cmd.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchDefaultsAreK8HybridNoJson() {
|
||||||
|
val cmd = parseArgs(arrayOf("search", "/tmp/x", "вопрос"))
|
||||||
|
assertTrue(cmd is SearchCmd, "ожидался SearchCmd, получено $cmd")
|
||||||
|
assertEquals(8, cmd.k)
|
||||||
|
assertEquals(SearchMode.HYBRID, cmd.mode)
|
||||||
|
assertEquals(false, cmd.json)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchFlagsParsed() {
|
||||||
|
val cmd = parseArgs(
|
||||||
|
arrayOf("search", "/tmp/x", "вопрос", "--k", "3", "--mode", "lex", "--json")
|
||||||
|
)
|
||||||
|
assertTrue(cmd is SearchCmd, "ожидался SearchCmd, получено $cmd")
|
||||||
|
assertEquals(3, cmd.k)
|
||||||
|
assertEquals(SearchMode.LEX, cmd.mode)
|
||||||
|
assertEquals(true, cmd.json)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun badKValueFallsBackToHelp() {
|
||||||
|
val cmd = parseArgs(arrayOf("search", "/tmp/x", "вопрос", "--k", "abc"))
|
||||||
|
assertTrue(cmd is HelpCmd, "ожидался HelpCmd, получено $cmd")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unknownCommandFallsBackToHelp() {
|
||||||
|
val cmd = parseArgs(arrayOf("сломать"))
|
||||||
|
assertTrue(cmd is HelpCmd, "ожидался HelpCmd, получено $cmd")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("pw.binom.db:ksqlite:0.1.4")
|
api("pw.binom.db:ksqlite:0.1.4")
|
||||||
implementation("pw.binom.ai.embeddingtext:api:5")
|
implementation("pw.binom.ai.embeddingtext:api:5")
|
||||||
runtimeOnly("pw.binom.ai.embeddingtext:siglip-jvm:5")
|
runtimeOnly("pw.binom.ai.embeddingtext:siglip-jvm:5")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user