@veewo/claw-core 0.1.85 → 0.1.87

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 (46) hide show
  1. package/dist/src/context.js +5 -1
  2. package/dist/src/context.js.map +1 -1
  3. package/dist/src/embedding-daemon-protocol.d.ts +4 -0
  4. package/dist/src/embedding-daemon-protocol.js +9 -3
  5. package/dist/src/embedding-daemon-protocol.js.map +1 -1
  6. package/dist/src/embedding-defaults.d.ts +1 -1
  7. package/dist/src/embedding-defaults.js +4 -2
  8. package/dist/src/embedding-defaults.js.map +1 -1
  9. package/dist/src/embedding-token-chunker.d.ts +10 -0
  10. package/dist/src/embedding-token-chunker.js +123 -0
  11. package/dist/src/embedding-token-chunker.js.map +1 -0
  12. package/dist/src/embedding-worker.js +32 -5
  13. package/dist/src/embedding-worker.js.map +1 -1
  14. package/dist/src/index.d.ts +2 -0
  15. package/dist/src/index.js +2 -0
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/init.js +2 -1
  18. package/dist/src/init.js.map +1 -1
  19. package/dist/src/knowledge-document.d.ts +27 -0
  20. package/dist/src/knowledge-document.js +189 -0
  21. package/dist/src/knowledge-document.js.map +1 -0
  22. package/dist/src/knowledge-governance.d.ts +22 -0
  23. package/dist/src/knowledge-governance.js +75 -0
  24. package/dist/src/knowledge-governance.js.map +1 -0
  25. package/dist/src/knowledge-sidecar.d.ts +3 -0
  26. package/dist/src/knowledge-sidecar.js +1 -0
  27. package/dist/src/knowledge-sidecar.js.map +1 -1
  28. package/dist/src/memory-query.d.ts +2 -0
  29. package/dist/src/memory-query.js +25 -4
  30. package/dist/src/memory-query.js.map +1 -1
  31. package/dist/src/memory.js +442 -65
  32. package/dist/src/memory.js.map +1 -1
  33. package/dist/src/plan-templates.d.ts +1 -0
  34. package/dist/src/plan-templates.js +61 -10
  35. package/dist/src/plan-templates.js.map +1 -1
  36. package/dist/src/project-check.js +6 -1
  37. package/dist/src/project-check.js.map +1 -1
  38. package/dist/src/project-defaults.d.ts +1 -0
  39. package/dist/src/project-defaults.js +1 -0
  40. package/dist/src/project-defaults.js.map +1 -1
  41. package/dist/src/templates/plans/default.d.ts +1 -0
  42. package/dist/src/templates/plans/default.js +3 -2
  43. package/dist/src/templates/plans/default.js.map +1 -1
  44. package/dist/src/types.d.ts +6 -0
  45. package/dist/src/workflow-guidance.config.json +4 -4
  46. package/package.json +1 -1
@@ -10,6 +10,7 @@ import { resolveDefaultLocalEmbeddingDimensions } from "./embedding-defaults.js"
10
10
  import { requestPersistentEmbedding } from "./embedding-daemon-protocol.js";
11
11
  import { ClawError } from "./errors.js";
12
12
  import { readJsonFile, readTextFile } from "./io.js";
13
+ import { analyzeKnowledgeDocument } from "./knowledge-document.js";
13
14
  import { buildProjectKeywordSearchPlan, buildProjectQueryIntent } from "./memory-query.js";
14
15
  const DEFAULT_PROJECT_REFRESH_FILE_LIMIT = 100;
15
16
  const PROJECT_SEARCH_CANDIDATE_MULTIPLIER = 8;
@@ -21,6 +22,7 @@ const DEFAULT_EMBEDDING_MAX_CHARS = DEFAULT_EMBEDDING_MAX_TOKENS * DEFAULT_EMBED
21
22
  const DEFAULT_MEMORY_SQLITE_BUSY_TIMEOUT_MS = 5000;
22
23
  const PROJECT_QUERY_EMBEDDING_CACHE_LIMIT = 128;
23
24
  const PROJECT_QUERY_EMBEDDING_CACHE_VERSION = "v1";
25
+ const PROJECT_EMBEDDING_CHUNKING_VERSION = "generic-knowledge-markers-v3";
24
26
  export function buildMemoryIndex(input) {
25
27
  const { scope, project, task } = resolveMemoryScope(input);
26
28
  if (!isProjectMemoryEnabled(project)) {
@@ -51,9 +53,11 @@ export function buildMemoryIndex(input) {
51
53
  upsertMetadata(db, "indexed_at", new Date().toISOString());
52
54
  if (embedding) {
53
55
  upsertMetadata(db, "embedding_config", JSON.stringify(embedding));
56
+ upsertMetadata(db, "embedding_chunking_version", PROJECT_EMBEDDING_CHUNKING_VERSION);
54
57
  }
55
58
  else {
56
59
  deleteMetadata(db, "embedding_config");
60
+ deleteMetadata(db, "embedding_chunking_version");
57
61
  }
58
62
  if (syncResult.vectorIndex) {
59
63
  upsertMetadata(db, "vector_index", JSON.stringify(syncResult.vectorIndex));
@@ -87,8 +91,11 @@ function rebuildTaskMemoryIndex(db, sources) {
87
91
  function syncProjectMemoryIndex(db, sources, embedding, maxFiles) {
88
92
  const currentEmbeddingConfig = embedding ? JSON.stringify(embedding) : null;
89
93
  const storedEmbeddingConfig = getMetadata(db, "embedding_config");
94
+ const storedChunkingVersion = getMetadata(db, "embedding_chunking_version");
90
95
  const shouldIndexVectors = canBuildProjectVectors(embedding);
91
- const requiresVectorReset = storedEmbeddingConfig !== currentEmbeddingConfig || !shouldIndexVectors;
96
+ const requiresVectorReset = storedEmbeddingConfig !== currentEmbeddingConfig
97
+ || storedChunkingVersion !== PROJECT_EMBEDDING_CHUNKING_VERSION
98
+ || !shouldIndexVectors;
92
99
  const nextSources = sources.map((source) => ({
93
100
  ...source,
94
101
  contentHash: hashMemoryContent(source.content),
@@ -446,6 +453,11 @@ function prepareSchema(db) {
446
453
  " source_path TEXT NOT NULL,",
447
454
  " kind TEXT NOT NULL,",
448
455
  " chunk_text TEXT NOT NULL,",
456
+ " heading_path TEXT NOT NULL DEFAULT '',",
457
+ " document_kind TEXT NOT NULL DEFAULT 'other',",
458
+ " document_state TEXT,",
459
+ " chunk_state TEXT,",
460
+ " dated TEXT,",
449
461
  " embedding_json TEXT NOT NULL,",
450
462
  " PRIMARY KEY (doc_id, chunk_index)",
451
463
  ");",
@@ -462,6 +474,19 @@ function prepareSchema(db) {
462
474
  if (!docsColumns.some((column) => column.name === "content_hash")) {
463
475
  db.exec("ALTER TABLE docs ADD COLUMN content_hash TEXT;");
464
476
  }
477
+ const embeddingColumns = db.prepare("PRAGMA table_info(doc_embeddings)").all();
478
+ const embeddingMigrations = [
479
+ ["heading_path", "TEXT NOT NULL DEFAULT ''"],
480
+ ["document_kind", "TEXT NOT NULL DEFAULT 'other'"],
481
+ ["document_state", "TEXT"],
482
+ ["chunk_state", "TEXT"],
483
+ ["dated", "TEXT"],
484
+ ];
485
+ for (const [name, declaration] of embeddingMigrations) {
486
+ if (!embeddingColumns.some((column) => column.name === name)) {
487
+ db.exec(`ALTER TABLE doc_embeddings ADD COLUMN ${name} ${declaration};`);
488
+ }
489
+ }
465
490
  }
466
491
  function resolveProjectMemoryEmbeddingConfig(project) {
467
492
  if (!isProjectMemoryEnabled(project)) {
@@ -569,32 +594,56 @@ function generateDocEmbeddings(docs, embedding) {
569
594
  if (!canBuildProjectVectors(embedding)) {
570
595
  return [];
571
596
  }
572
- const chunks = docs.flatMap((doc) => chunkMarkdownContent(doc.content).map((chunkText, chunkIndex) => ({
597
+ const chunks = docs.flatMap((doc) => chunkMarkdownContent(doc.content, doc.sourcePath, doc.kind).map((chunk, chunkIndex) => ({
573
598
  docId: doc.docId,
574
599
  chunkIndex,
575
600
  sourcePath: doc.sourcePath,
576
601
  kind: doc.kind,
577
- chunkText,
602
+ ...chunk,
578
603
  })));
579
604
  if (chunks.length === 0) {
580
605
  return [];
581
606
  }
582
607
  const output = runEmbeddingWorker({
583
608
  embedding,
584
- texts: chunks.map((chunk) => chunk.chunkText),
609
+ texts: chunks.map((chunk) => embedding.provider === "local"
610
+ ? chunk.bodyText
611
+ : joinChunkContext(chunk.contextPrefix, chunk.bodyText)),
612
+ ...(embedding.provider === "local"
613
+ ? { textPrefixes: chunks.map((chunk) => chunk.contextPrefix) }
614
+ : {}),
615
+ splitIntoTokenWindows: embedding.provider === "local",
585
616
  });
586
- return chunks.map((chunk, index) => ({
587
- ...chunk,
588
- vector: output.vectors[index] ?? [],
617
+ const segments = output.segments ?? chunks.map((chunk, sourceTextIndex) => ({
618
+ sourceTextIndex,
619
+ text: joinChunkContext(chunk.contextPrefix, chunk.bodyText),
589
620
  }));
621
+ if (output.vectors.length !== segments.length) {
622
+ throw new Error(`Embedding worker returned ${output.vectors.length} vectors for ${segments.length} text segments.`);
623
+ }
624
+ const nextChunkIndexByDoc = new Map();
625
+ return segments.map((segment, index) => {
626
+ const source = chunks[segment.sourceTextIndex];
627
+ if (!source) {
628
+ throw new Error(`Embedding worker returned an invalid source text index: ${segment.sourceTextIndex}`);
629
+ }
630
+ const chunkIndex = nextChunkIndexByDoc.get(source.docId) ?? 0;
631
+ nextChunkIndexByDoc.set(source.docId, chunkIndex + 1);
632
+ return {
633
+ ...source,
634
+ chunkIndex,
635
+ chunkText: segment.text,
636
+ vector: output.vectors[index] ?? [],
637
+ };
638
+ });
590
639
  }
591
640
  function insertDocEmbeddings(db, embeddings) {
592
641
  const insertEmbedding = db.prepare([
593
- "INSERT OR REPLACE INTO doc_embeddings (doc_id, chunk_index, source_path, kind, chunk_text, embedding_json)",
594
- "VALUES (?, ?, ?, ?, ?, ?)",
642
+ "INSERT OR REPLACE INTO doc_embeddings (doc_id, chunk_index, source_path, kind, chunk_text, heading_path, document_kind, document_state, chunk_state, dated, embedding_json)",
643
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
595
644
  ].join(" "));
596
645
  embeddings.forEach((embedding) => {
597
- insertEmbedding.run(embedding.docId, embedding.chunkIndex, embedding.sourcePath, embedding.kind, embedding.chunkText, JSON.stringify(embedding.vector));
646
+ insertEmbedding.run(embedding.docId, embedding.chunkIndex, embedding.sourcePath, embedding.kind, embedding.chunkText, embedding.headingPath, embedding.documentKind, embedding.documentState, embedding.state, embedding.dated, JSON.stringify(embedding.vector));
598
647
  });
599
648
  }
600
649
  function listDocsMissingEmbeddings(db) {
@@ -647,44 +696,158 @@ function canBuildProjectVectors(embedding) {
647
696
  function hashMemoryContent(content) {
648
697
  return createHash("sha256").update(content, "utf8").digest("hex");
649
698
  }
650
- function chunkMarkdownContent(content) {
651
- const paragraphChunks = content
652
- .split(/\r?\n\s*\r?\n/g)
653
- .map((chunk) => chunk.trim())
654
- .filter((chunk) => chunk.length > 0)
655
- .flatMap((chunk) => splitOversizedMarkdownChunk(chunk));
656
- const mergedChunks = [];
657
- let current = "";
658
- for (const chunk of paragraphChunks) {
659
- if (!current) {
660
- current = chunk;
661
- continue;
699
+ function chunkMarkdownContent(content, sourcePath, sourceKind) {
700
+ const isCanonicalKnowledge = sourceKind === "truth_doc";
701
+ const analysis = isCanonicalKnowledge ? analyzeKnowledgeDocument(content, sourcePath) : null;
702
+ const documentKind = analysis?.kind ?? "other";
703
+ const documentState = analysis?.state ?? null;
704
+ const headingStack = [];
705
+ let inFence = false;
706
+ let pendingSectionState = null;
707
+ let pendingDate = null;
708
+ const paragraphs = content
709
+ .split(/\r?\n\s*\r?\n/gu)
710
+ .map((paragraph) => paragraph.trim())
711
+ .filter(Boolean);
712
+ const pieces = [];
713
+ for (const paragraph of paragraphs) {
714
+ const bodyLines = [];
715
+ for (const line of paragraph.split(/\r?\n/gu)) {
716
+ if (/^\s*(```|~~~)/u.test(line)) {
717
+ inFence = !inFence;
718
+ bodyLines.push(line);
719
+ continue;
720
+ }
721
+ if (inFence) {
722
+ bodyLines.push(line);
723
+ continue;
724
+ }
725
+ const sectionState = parseKnowledgeStateComment(line);
726
+ if (sectionState) {
727
+ pendingSectionState = sectionState;
728
+ pendingDate = null;
729
+ continue;
730
+ }
731
+ const dated = parseKnowledgeDatedComment(line);
732
+ if (dated) {
733
+ pendingDate = dated;
734
+ continue;
735
+ }
736
+ if (isKnowledgeDocumentStateComment(line)) {
737
+ continue;
738
+ }
739
+ bodyLines.push(line);
740
+ const heading = /^(#{1,6})\s+(.+?)\s*$/u.exec(line.trim());
741
+ if (!heading) {
742
+ continue;
743
+ }
744
+ const level = heading[1].length;
745
+ const replaceFrom = headingStack.findIndex((entry) => entry.level >= level);
746
+ if (replaceFrom >= 0) {
747
+ headingStack.splice(replaceFrom);
748
+ }
749
+ const headingText = heading[2].trim();
750
+ headingStack.push({
751
+ level,
752
+ text: headingText,
753
+ state: pendingSectionState,
754
+ dated: pendingDate,
755
+ });
756
+ pendingSectionState = null;
757
+ pendingDate = null;
662
758
  }
663
- if ((`${current}\n\n${chunk}`).length <= DEFAULT_EMBEDDING_TARGET_CHARS) {
664
- current = `${current}\n\n${chunk}`;
759
+ const bodyParagraph = bodyLines.join("\n").trim();
760
+ if (!bodyParagraph) {
665
761
  continue;
666
762
  }
667
- mergedChunks.push(current);
668
- current = chunk;
763
+ const headingPath = headingStack.map((entry) => entry.text).join(" > ");
764
+ const state = [...headingStack].reverse().find((entry) => entry.state)?.state
765
+ ?? documentState;
766
+ const dated = [...headingStack].reverse().find((entry) => entry.dated)?.dated ?? null;
767
+ const contextPrefix = buildChunkContextPrefix({
768
+ headingPath,
769
+ documentKind,
770
+ documentState,
771
+ state,
772
+ dated,
773
+ });
774
+ const contextSeparatorLength = contextPrefix ? 2 : 0;
775
+ const maxBodyChars = Math.max(256, DEFAULT_EMBEDDING_MAX_CHARS - contextPrefix.length - contextSeparatorLength);
776
+ const targetBodyChars = Math.max(128, DEFAULT_EMBEDDING_TARGET_CHARS - contextPrefix.length - contextSeparatorLength);
777
+ for (const bodyText of splitOversizedMarkdownChunk(bodyParagraph, maxBodyChars, targetBodyChars)) {
778
+ pieces.push({
779
+ bodyText,
780
+ contextPrefix,
781
+ headingPath,
782
+ documentKind,
783
+ documentState,
784
+ state,
785
+ dated,
786
+ });
787
+ }
669
788
  }
670
- if (current) {
671
- mergedChunks.push(current);
789
+ const merged = [];
790
+ for (const piece of pieces) {
791
+ const previous = merged[merged.length - 1];
792
+ if (previous
793
+ && sameChunkContext(previous, piece)
794
+ && joinChunkContext(previous.contextPrefix, `${previous.bodyText}\n\n${piece.bodyText}`).length
795
+ <= DEFAULT_EMBEDDING_TARGET_CHARS) {
796
+ previous.bodyText = `${previous.bodyText}\n\n${piece.bodyText}`;
797
+ }
798
+ else {
799
+ merged.push({ ...piece });
800
+ }
801
+ }
802
+ return merged;
803
+ }
804
+ function parseKnowledgeStateComment(line) {
805
+ const match = /^\s*<!--\s*state:\s*(current|accepted|history|historical|superseded)\s*-->\s*$/iu.exec(line);
806
+ const value = match?.[1].toLowerCase();
807
+ if (value === "history" || value === "historical") {
808
+ return "historical";
672
809
  }
673
- return mergedChunks;
810
+ return value === "current" || value === "accepted" || value === "superseded" ? value : null;
674
811
  }
675
- function splitOversizedMarkdownChunk(chunk) {
676
- if (chunk.length <= DEFAULT_EMBEDDING_MAX_CHARS) {
812
+ function parseKnowledgeDatedComment(line) {
813
+ return /^\s*<!--\s*dated:\s*(\d{4}-\d{2}-\d{2})\s*-->\s*$/iu.exec(line)?.[1] ?? null;
814
+ }
815
+ function isKnowledgeDocumentStateComment(line) {
816
+ return /^\s*<!--\s*document-state:\s*(?:current|accepted|history|historical|superseded)\s*-->\s*$/iu.test(line);
817
+ }
818
+ function buildChunkContextPrefix(input) {
819
+ const parts = [];
820
+ if (input.documentKind !== "other") {
821
+ parts.push(`[knowledge:doc=${input.documentKind} doc_state=${input.documentState ?? "unknown"} state=${input.state ?? "unknown"}${input.dated ? ` dated=${input.dated}` : ""}]`);
822
+ }
823
+ if (input.headingPath) {
824
+ parts.push(`Heading: ${input.headingPath}`);
825
+ }
826
+ return parts.join("\n");
827
+ }
828
+ function joinChunkContext(prefix, body) {
829
+ return prefix ? `${prefix}\n\n${body}` : body;
830
+ }
831
+ function sameChunkContext(left, right) {
832
+ return left.contextPrefix === right.contextPrefix
833
+ && left.documentKind === right.documentKind
834
+ && left.documentState === right.documentState
835
+ && left.state === right.state
836
+ && left.dated === right.dated;
837
+ }
838
+ function splitOversizedMarkdownChunk(chunk, maxChars = DEFAULT_EMBEDDING_MAX_CHARS, targetChars = DEFAULT_EMBEDDING_TARGET_CHARS) {
839
+ if (chunk.length <= maxChars) {
677
840
  return [chunk];
678
841
  }
679
842
  const pieces = [];
680
843
  let start = 0;
681
844
  while (start < chunk.length) {
682
845
  const remaining = chunk.length - start;
683
- if (remaining <= DEFAULT_EMBEDDING_MAX_CHARS) {
846
+ if (remaining <= maxChars) {
684
847
  pieces.push(chunk.slice(start).trim());
685
848
  break;
686
849
  }
687
- const preferredSplit = findPreferredChunkBoundary(chunk, start, Math.min(start + DEFAULT_EMBEDDING_TARGET_CHARS, chunk.length), Math.min(start + DEFAULT_EMBEDDING_MAX_CHARS, chunk.length));
850
+ const preferredSplit = findPreferredChunkBoundary(chunk, start, Math.min(start + targetChars, chunk.length), Math.min(start + maxChars, chunk.length), targetChars);
688
851
  pieces.push(chunk.slice(start, preferredSplit).trim());
689
852
  start = preferredSplit;
690
853
  while (start < chunk.length && /\s/.test(chunk[start] ?? "")) {
@@ -693,8 +856,8 @@ function splitOversizedMarkdownChunk(chunk) {
693
856
  }
694
857
  return pieces.filter((piece) => piece.length > 0);
695
858
  }
696
- function findPreferredChunkBoundary(text, start, preferredEnd, hardEnd) {
697
- const lowerBound = Math.max(start + Math.floor(DEFAULT_EMBEDDING_TARGET_CHARS / 2), start + 1);
859
+ function findPreferredChunkBoundary(text, start, preferredEnd, hardEnd, targetChars = DEFAULT_EMBEDDING_TARGET_CHARS) {
860
+ const lowerBound = Math.max(start + Math.floor(targetChars / 2), start + 1);
698
861
  for (let index = preferredEnd; index >= lowerBound; index -= 1) {
699
862
  const current = text[index];
700
863
  const previous = text[index - 1];
@@ -745,10 +908,10 @@ function runEmbeddingWorker(input) {
745
908
  function resolveEmbeddingWorkerTimeoutMs() {
746
909
  const raw = process.env.CLAW_EMBEDDING_WORKER_TIMEOUT_MS?.trim();
747
910
  if (!raw) {
748
- return 30 * 60 * 1000;
911
+ return 2 * 60 * 60 * 1000;
749
912
  }
750
913
  const parsed = Number(raw);
751
- return Number.isFinite(parsed) && parsed > 0 ? parsed : 30 * 60 * 1000;
914
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 2 * 60 * 60 * 1000;
752
915
  }
753
916
  function stripBom(content) {
754
917
  return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
@@ -804,6 +967,7 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
804
967
  throw new ClawError("MEMORY_VECTOR_INDEX_REQUIRED", "Project search requires a refreshed vector index. Run `claw search index --refresh` first.");
805
968
  }
806
969
  const queryIntent = buildProjectQueryIntent(query);
970
+ const temporalIntent = detectTemporalQueryIntent(query);
807
971
  const candidateLimit = Math.max(limit * PROJECT_SEARCH_CANDIDATE_MULTIPLIER, 40);
808
972
  const projectDocs = db
809
973
  .prepare("SELECT source_path, kind, content FROM docs")
@@ -839,7 +1003,7 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
839
1003
  const queryEmbedding = resolveProjectQueryEmbedding(db, embedding, queryIntent.embeddingText || query);
840
1004
  const vectorRows = db
841
1005
  .prepare([
842
- "SELECT source_path, kind, chunk_text, embedding_json",
1006
+ "SELECT source_path, kind, chunk_text, heading_path, document_kind, document_state, chunk_state, dated, embedding_json",
843
1007
  "FROM doc_embeddings",
844
1008
  ].join(" "))
845
1009
  .all();
@@ -855,30 +1019,48 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
855
1019
  snippet: buildSnippet(row.chunk_text),
856
1020
  similarity: cosineSimilarity(queryEmbedding.vector, parseEmbeddingJson(row.embedding_json)),
857
1021
  exactBoost: signals?.exactBoost ?? 0,
1022
+ temporalBoost: calculateTemporalChunkBoost({
1023
+ intent: temporalIntent,
1024
+ documentState: row.document_state,
1025
+ state: row.chunk_state,
1026
+ dated: row.dated,
1027
+ }),
1028
+ documentKind: row.document_kind,
1029
+ documentState: row.document_state,
1030
+ state: row.chunk_state,
1031
+ dated: row.dated,
1032
+ headingPath: row.heading_path,
858
1033
  };
859
1034
  })
860
1035
  .filter((row) => Number.isFinite(row.similarity))
861
1036
  .sort((left, right) => {
862
- const leftScore = left.similarity + left.exactBoost;
863
- const rightScore = right.similarity + right.exactBoost;
1037
+ const leftScore = left.similarity + left.exactBoost + left.temporalBoost;
1038
+ const rightScore = right.similarity + right.exactBoost + right.temporalBoost;
864
1039
  return rightScore - leftScore;
865
1040
  });
866
1041
  const bestVectorBySource = new Map();
867
1042
  for (const row of rankedVectors) {
868
1043
  const existing = bestVectorBySource.get(row.sourcePath);
869
- if (!existing || row.similarity > existing.similarity) {
1044
+ const vectorScore = row.similarity + row.temporalBoost;
1045
+ if (!existing || vectorScore > existing.vectorScore) {
870
1046
  bestVectorBySource.set(row.sourcePath, {
871
1047
  kind: row.kind,
872
1048
  snippet: row.snippet,
873
1049
  similarity: row.similarity,
1050
+ vectorScore,
1051
+ documentKind: row.documentKind,
1052
+ documentState: row.documentState,
1053
+ state: row.state,
1054
+ dated: row.dated,
1055
+ headingPath: row.headingPath,
874
1056
  });
875
1057
  }
876
1058
  }
877
1059
  const fused = new Map();
878
1060
  Array.from(bestVectorBySource.entries())
879
1061
  .sort((left, right) => {
880
- const leftScore = left[1].similarity + (docSignals.get(left[0])?.exactBoost ?? 0);
881
- const rightScore = right[1].similarity + (docSignals.get(right[0])?.exactBoost ?? 0);
1062
+ const leftScore = left[1].vectorScore + (docSignals.get(left[0])?.exactBoost ?? 0);
1063
+ const rightScore = right[1].vectorScore + (docSignals.get(right[0])?.exactBoost ?? 0);
882
1064
  return rightScore - leftScore;
883
1065
  })
884
1066
  .slice(0, candidateLimit)
@@ -888,14 +1070,19 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
888
1070
  sourcePath,
889
1071
  kind: row.kind,
890
1072
  snippet: row.snippet,
891
- score: reciprocalRankScore(index + 1, 0.6) + (signals?.exactBoost ?? 0),
1073
+ score: reciprocalRankScore(index + 1, 0.5) + (signals?.exactBoost ?? 0),
892
1074
  vectorRank: index + 1,
1075
+ documentKind: row.documentKind,
1076
+ documentState: row.documentState,
1077
+ state: row.state,
1078
+ dated: row.dated,
1079
+ headingPath: row.headingPath,
893
1080
  });
894
1081
  });
895
1082
  ftsRows.forEach((row, index) => {
896
1083
  const existing = fused.get(row.source_path);
897
1084
  const signals = docSignals.get(row.source_path);
898
- const nextScore = (existing?.score ?? 0) + reciprocalRankScore(index + 1, 0.25);
1085
+ const nextScore = (existing?.score ?? 0) + reciprocalRankScore(index + 1, 0.3);
899
1086
  fused.set(row.source_path, {
900
1087
  sourcePath: row.source_path,
901
1088
  kind: existing?.kind ?? row.kind,
@@ -903,12 +1090,13 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
903
1090
  score: nextScore + (existing ? 0 : (signals?.exactBoost ?? 0)),
904
1091
  vectorRank: existing?.vectorRank,
905
1092
  textRank: index + 1,
1093
+ ...(existing ? pickTemporalResultMetadata(existing) : {}),
906
1094
  });
907
1095
  });
908
1096
  signalRows.forEach((row, index) => {
909
1097
  const existing = fused.get(row.source_path);
910
1098
  const signals = docSignals.get(row.source_path);
911
- const nextScore = (existing?.score ?? 0) + reciprocalRankScore(index + 1, 0.15);
1099
+ const nextScore = (existing?.score ?? 0) + reciprocalRankScore(index + 1, 0.2);
912
1100
  fused.set(row.source_path, {
913
1101
  sourcePath: row.source_path,
914
1102
  kind: existing?.kind ?? row.kind,
@@ -917,6 +1105,7 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
917
1105
  vectorRank: existing?.vectorRank,
918
1106
  textRank: existing?.textRank,
919
1107
  signalRank: index + 1,
1108
+ ...(existing ? pickTemporalResultMetadata(existing) : {}),
920
1109
  });
921
1110
  });
922
1111
  return {
@@ -931,21 +1120,33 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
931
1120
  function tryProjectLexicalFastPath(input) {
932
1121
  const { queryIntent } = input;
933
1122
  const primaryKeywordStep = buildProjectKeywordSearchPlan(input.query)[0];
934
- if (queryIntent.strongTerms.length === 0
935
- || queryIntent.weakTerms.length > 0
936
- || !primaryKeywordStep
937
- || primaryKeywordStep.substringTerms.length > 0) {
1123
+ const normalizedFileQuery = path.basename(input.query.trim()).toLowerCase();
1124
+ const normalizedFileStem = path.parse(normalizedFileQuery).name;
1125
+ const explicitFileMatches = /\.[a-z0-9]{1,8}$/iu.test(normalizedFileQuery)
1126
+ ? Array.from(input.docSignals.keys()).filter((sourcePath) => {
1127
+ const candidateName = path.basename(sourcePath).toLowerCase();
1128
+ return candidateName === normalizedFileQuery
1129
+ || path.parse(candidateName).name === normalizedFileStem;
1130
+ })
1131
+ : [];
1132
+ if (explicitFileMatches.length !== 1
1133
+ && (queryIntent.strongTerms.length === 0
1134
+ || queryIntent.weakTerms.length > 0
1135
+ || !primaryKeywordStep
1136
+ || primaryKeywordStep.substringTerms.length > 0)) {
938
1137
  return null;
939
1138
  }
940
1139
  const fullCoverage = Array.from(input.docSignals.entries()).filter(([, signals]) => signals.strongCoverageRatio === 1);
941
1140
  const pathMatches = fullCoverage.filter(([, signals]) => signals.fileNameHits >= queryIntent.strongTerms.length
942
1141
  || signals.pathHits >= queryIntent.strongTerms.length);
943
1142
  const phraseMatches = fullCoverage.filter(([, signals]) => signals.phraseMatch);
944
- const confidentSourcePath = pathMatches.length === 1
945
- ? pathMatches[0]?.[0]
946
- : phraseMatches.length === 1
947
- ? phraseMatches[0]?.[0]
948
- : null;
1143
+ const confidentSourcePath = explicitFileMatches.length === 1
1144
+ ? explicitFileMatches[0]
1145
+ : pathMatches.length === 1
1146
+ ? pathMatches[0]?.[0]
1147
+ : phraseMatches.length === 1
1148
+ ? phraseMatches[0]?.[0]
1149
+ : null;
949
1150
  if (!confidentSourcePath) {
950
1151
  return null;
951
1152
  }
@@ -1172,6 +1373,53 @@ function matchesAllSubstrings(db, sourcePath, substringTerms) {
1172
1373
  function escapeLikePattern(term) {
1173
1374
  return term.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
1174
1375
  }
1376
+ function detectTemporalQueryIntent(query) {
1377
+ const normalized = query.trim().toLowerCase();
1378
+ const date = /\b(\d{4}-\d{2}-\d{2})\b/u.exec(normalized)?.[1] ?? null;
1379
+ const historical = /(?:\bwhy\b|\brationale\b|\bhistor(?:y|ical)\b|\bprevious(?:ly)?\b|\bpast\b|\bformerly\b|\brollback\b|\brevert(?:ed)?\b|\bused to\b|为什么|原因|历史|过去|之前|曾经|当时|回退|回滚|反复|演化|旧方案)/iu.test(normalized);
1380
+ if (historical || date) {
1381
+ return { mode: "historical", date };
1382
+ }
1383
+ const current = /(?:\bcurrent(?:ly)?\b|\bnow\b|\bpresent\b|\bfinal\b|\blatest\b|\bas of today\b|当前|现在|目前|最终|现行|如今|为准|怎么工作|如何工作)/iu.test(normalized);
1384
+ return { mode: current ? "current" : "neutral", date };
1385
+ }
1386
+ function calculateTemporalChunkBoost(input) {
1387
+ const effectiveState = input.state ?? input.documentState;
1388
+ let score = 0;
1389
+ if (input.intent.mode === "current") {
1390
+ if (effectiveState === "current" || effectiveState === "accepted")
1391
+ score += 0.035;
1392
+ else if (effectiveState === "historical")
1393
+ score -= 0.025;
1394
+ else if (effectiveState === "superseded")
1395
+ score -= 0.04;
1396
+ }
1397
+ else if (input.intent.mode === "historical") {
1398
+ if (effectiveState === "historical")
1399
+ score += 0.035;
1400
+ else if (effectiveState === "superseded")
1401
+ score += 0.02;
1402
+ }
1403
+ else if (effectiveState === "current" || effectiveState === "accepted") {
1404
+ score += 0.012;
1405
+ }
1406
+ else if (effectiveState === "superseded") {
1407
+ score -= 0.025;
1408
+ }
1409
+ if (input.intent.date && input.dated === input.intent.date) {
1410
+ score += 0.05;
1411
+ }
1412
+ return score;
1413
+ }
1414
+ function pickTemporalResultMetadata(entry) {
1415
+ return {
1416
+ ...(entry.documentKind !== undefined ? { documentKind: entry.documentKind } : {}),
1417
+ ...(entry.documentState !== undefined ? { documentState: entry.documentState } : {}),
1418
+ ...(entry.state !== undefined ? { state: entry.state } : {}),
1419
+ ...(entry.dated !== undefined ? { dated: entry.dated } : {}),
1420
+ ...(entry.headingPath !== undefined ? { headingPath: entry.headingPath } : {}),
1421
+ };
1422
+ }
1175
1423
  function rerankProjectSearchCandidates(candidates, docSignals, limit) {
1176
1424
  const remaining = [...candidates];
1177
1425
  const selected = [];
@@ -1187,8 +1435,8 @@ function rerankProjectSearchCandidates(candidates, docSignals, limit) {
1187
1435
  const uncoveredStrongTerms = (signals?.strongMatchedTerms ?? []).filter((term) => !coveredStrongTerms.has(term));
1188
1436
  const uncoveredTerms = (signals?.matchedTerms ?? []).filter((term) => !coveredTerms.has(term));
1189
1437
  const adjustedScore = candidate.score
1190
- + uncoveredStrongTerms.length * 0.045
1191
- + uncoveredTerms.length * 0.01
1438
+ + uncoveredStrongTerms.length * 0.015
1439
+ + uncoveredTerms.length * 0.003
1192
1440
  + Math.max(routeCount - 1, 0) * 0.003;
1193
1441
  if (adjustedScore > bestScore) {
1194
1442
  bestScore = adjustedScore;
@@ -1224,6 +1472,27 @@ function rerankProjectSearchCandidates(candidates, docSignals, limit) {
1224
1472
  kind: next.kind,
1225
1473
  snippet: next.snippet,
1226
1474
  score: next.score,
1475
+ ...pickTemporalResultMetadata(next),
1476
+ ...(process.env.CLAW_SEARCH_RANKING_DIAGNOSTICS === "1"
1477
+ ? {
1478
+ _ranking: {
1479
+ vectorRank: next.vectorRank ?? null,
1480
+ textRank: next.textRank ?? null,
1481
+ signalRank: next.signalRank ?? null,
1482
+ baseLexicalBoost: (nextSignals?.exactBoost ?? 0)
1483
+ - (nextSignals?.titleMatchScore ?? 0)
1484
+ - (nextSignals?.entityTitleScore ?? 0)
1485
+ - (nextSignals?.documentTypeScore ?? 0)
1486
+ + (nextSignals?.genericPenalty ?? 0),
1487
+ titleMatchScore: nextSignals?.titleMatchScore ?? 0,
1488
+ entityTitleScore: nextSignals?.entityTitleScore ?? 0,
1489
+ documentTypeScore: nextSignals?.documentTypeScore ?? 0,
1490
+ genericPenalty: nextSignals?.genericPenalty ?? 0,
1491
+ matchedTerms: nextSignals?.matchedTerms ?? [],
1492
+ strongMatchedTerms: nextSignals?.strongMatchedTerms ?? [],
1493
+ },
1494
+ }
1495
+ : {}),
1227
1496
  });
1228
1497
  }
1229
1498
  return selected;
@@ -1233,6 +1502,7 @@ function buildProjectSearchSignals(input) {
1233
1502
  const normalizedContent = input.content.toLowerCase();
1234
1503
  const normalizedPath = input.sourcePath.toLowerCase();
1235
1504
  const fileName = path.basename(input.sourcePath).toLowerCase();
1505
+ const title = extractDocumentTitle(input.content, fileName);
1236
1506
  const lowerTerms = input.queryIntent.terms.map((term) => term.toLowerCase());
1237
1507
  const lowerStrongTerms = input.queryIntent.strongTerms.map((term) => term.toLowerCase());
1238
1508
  const lowerWeakTerms = input.queryIntent.weakTerms.map((term) => term.toLowerCase());
@@ -1251,12 +1521,11 @@ function buildProjectSearchSignals(input) {
1251
1521
  const phraseMatch = normalizedQuery.length > 0
1252
1522
  && (normalizedContent.includes(normalizedQuery.toLowerCase()) || normalizedPath.includes(normalizedQuery.toLowerCase()));
1253
1523
  const weakOnlyPenalty = strongMatchedTerms.size === 0 && weakMatchedTerms.size > 0 ? 0.012 : 0;
1254
- const missingStrongPenalty = lowerStrongTerms.length > 0 && strongMatchedTerms.size === 0
1255
- ? 0.035
1256
- : lowerStrongTerms.length > 1 && strongMatchedTerms.size === 1
1257
- ? 0.01
1258
- : 0;
1259
1524
  const indexFilePenalty = isIndexLikeDocName(fileName) ? 0.06 : 0;
1525
+ const titleMatchScore = calculateTitleMatchScore(normalizedQuery, title);
1526
+ const entityTitleScore = calculateEntityTitleScore(input.queryIntent.entityPhrases, title);
1527
+ const documentTypeScore = calculateDocumentTypeScore(normalizedQuery, input.sourcePath, title);
1528
+ const genericPenalty = calculateGenericDocumentPenalty(normalizedQuery, input.sourcePath, fileName, title);
1260
1529
  return {
1261
1530
  matchedTerms: Array.from(matchedTerms),
1262
1531
  strongMatchedTerms: Array.from(strongMatchedTerms),
@@ -1267,6 +1536,10 @@ function buildProjectSearchSignals(input) {
1267
1536
  pathHits,
1268
1537
  phraseMatch,
1269
1538
  strongCoverageRatio,
1539
+ titleMatchScore,
1540
+ entityTitleScore,
1541
+ documentTypeScore,
1542
+ genericPenalty,
1270
1543
  exactBoost: strongMatchedTerms.size * 0.016
1271
1544
  + weakMatchedTerms.size * 0.004
1272
1545
  + coverageRatio * 0.008
@@ -1275,13 +1548,117 @@ function buildProjectSearchSignals(input) {
1275
1548
  + fileNameHits * 0.025
1276
1549
  + pathHits * 0.01
1277
1550
  + (phraseMatch ? 0.018 : 0)
1551
+ + titleMatchScore
1552
+ + entityTitleScore
1553
+ + documentTypeScore
1278
1554
  - weakOnlyPenalty
1279
- - missingStrongPenalty
1280
- - indexFilePenalty,
1555
+ - indexFilePenalty
1556
+ - genericPenalty,
1281
1557
  };
1282
1558
  }
1559
+ function calculateEntityTitleScore(entityPhrases, title) {
1560
+ const normalizedTitle = normalizeComparableTitle(title);
1561
+ let score = 0;
1562
+ for (const phrase of entityPhrases) {
1563
+ const normalizedPhrase = normalizeComparableTitle(phrase);
1564
+ if (normalizedPhrase.length < 3) {
1565
+ continue;
1566
+ }
1567
+ if (normalizedPhrase === normalizedTitle) {
1568
+ score = Math.max(score, 0.14);
1569
+ }
1570
+ else if (normalizedTitle.includes(normalizedPhrase) || normalizedPhrase.includes(normalizedTitle)) {
1571
+ score = Math.max(score, 0.09);
1572
+ }
1573
+ }
1574
+ return score;
1575
+ }
1576
+ function normalizeComparableTitle(value) {
1577
+ return value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
1578
+ }
1283
1579
  function isIndexLikeDocName(fileName) {
1284
- return fileName === "contents.md" || fileName === "summary.md" || fileName === "index.md" || fileName === "readme.md";
1580
+ return fileName === "contents.md"
1581
+ || fileName === "summary.md"
1582
+ || fileName === "index.md"
1583
+ || fileName === "readme.md"
1584
+ || fileName === "project-truth.md";
1585
+ }
1586
+ function extractDocumentTitle(content, fileName) {
1587
+ const heading = content
1588
+ .split(/\r?\n/u, 40)
1589
+ .find((line) => /^#{1,3}\s+\S/u.test(line.trim()));
1590
+ return (heading?.replace(/^#{1,3}\s+/u, "") ?? path.parse(fileName).name).trim().toLowerCase();
1591
+ }
1592
+ function calculateTitleMatchScore(query, title) {
1593
+ const queryFeatures = buildTitleLexicalFeatures(query);
1594
+ const titleFeatures = buildTitleLexicalFeatures(title);
1595
+ if (queryFeatures.size === 0 || titleFeatures.size === 0) {
1596
+ return 0;
1597
+ }
1598
+ let matched = 0;
1599
+ for (const feature of titleFeatures) {
1600
+ if (queryFeatures.has(feature)) {
1601
+ matched += 1;
1602
+ }
1603
+ }
1604
+ if (matched < 2) {
1605
+ return 0;
1606
+ }
1607
+ const titleCoverage = matched / titleFeatures.size;
1608
+ return Math.min(0.075, matched * 0.006 + titleCoverage * 0.035);
1609
+ }
1610
+ function buildTitleLexicalFeatures(value) {
1611
+ const features = new Set();
1612
+ for (const asciiWord of value.toLowerCase().match(/[a-z][a-z0-9_-]{2,}/gu) ?? []) {
1613
+ features.add(`a:${asciiWord}`);
1614
+ }
1615
+ for (const sequence of value.match(/[\u4e00-\u9fff]+/gu) ?? []) {
1616
+ const characters = Array.from(sequence);
1617
+ for (let size = 2; size <= 3; size += 1) {
1618
+ for (let index = 0; index <= characters.length - size; index += 1) {
1619
+ features.add(`c:${characters.slice(index, index + size).join("")}`);
1620
+ }
1621
+ }
1622
+ }
1623
+ return features;
1624
+ }
1625
+ function calculateDocumentTypeScore(query, sourcePath, title) {
1626
+ const normalizedPath = sourcePath.replaceAll("\\", "/").toLowerCase();
1627
+ let score = 0;
1628
+ if (/(?:必须|不应该|优先|规则)/u.test(query) && /(?:规则|规范|指南)/u.test(title)) {
1629
+ score += 0.025;
1630
+ }
1631
+ if (/(?:有哪些|哪些|名单|清单|在哪里定义|哪里定义)/u.test(query) && /(?:名单|清单|目录|索引)/u.test(title)) {
1632
+ score += 0.025;
1633
+ }
1634
+ if (/(?:章节|第[一二三四五六七八九十百]+章)/u.test(query) && /第[一二三四五六七八九十百]+章/u.test(title)) {
1635
+ score += 0.025;
1636
+ }
1637
+ if (/(?:边界|允许|禁止|分工)/u.test(query) && /(?:边界|规范|规则|分工)/u.test(title)) {
1638
+ score += 0.02;
1639
+ }
1640
+ if (/(?:为什么|为何|决定|取舍)/u.test(query) && /(?:adr|方案|决策|裁定)/iu.test(title)) {
1641
+ score += 0.012;
1642
+ }
1643
+ if (/(?:现在|当前|最终|由谁|运行时)/u.test(query) && normalizedPath.includes("/.claw/truth/")) {
1644
+ score += 0.006;
1645
+ }
1646
+ return Math.min(score, 0.04);
1647
+ }
1648
+ function calculateGenericDocumentPenalty(query, sourcePath, fileName, title) {
1649
+ const normalizedPath = sourcePath.replaceAll("\\", "/").toLowerCase();
1650
+ const explicitlyRequestsPlanning = /(?:计划|规划|路线|规格|实施方案|复盘)/u.test(query);
1651
+ const planningLikePath = /\/(?:plans?|specs?|development-plans|[^/]*开发[^/]*计划)(?:\/|$)/iu.test(normalizedPath);
1652
+ if (planningLikePath && !explicitlyRequestsPlanning) {
1653
+ return 0.04;
1654
+ }
1655
+ if (/第[一二三四五六七八九十百]+章/u.test(title) && !/(?:章节|第[一二三四五六七八九十百]+章)/u.test(query)) {
1656
+ return 0.025;
1657
+ }
1658
+ if (/(?:总纲|索引|总览)/u.test(title) && !/(?:总纲|索引|总览|整体|全部)/u.test(query)) {
1659
+ return 0.035;
1660
+ }
1661
+ return isIndexLikeDocName(fileName) ? 0.01 : 0;
1285
1662
  }
1286
1663
  function reciprocalRankScore(rank, weight) {
1287
1664
  return weight * (1 / (40 + rank));