@veewo/claw-core 0.1.86 → 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 (39) hide show
  1. package/dist/src/context.js +5 -1
  2. package/dist/src/context.js.map +1 -1
  3. package/dist/src/embedding-token-chunker.d.ts +1 -0
  4. package/dist/src/embedding-token-chunker.js +11 -2
  5. package/dist/src/embedding-token-chunker.js.map +1 -1
  6. package/dist/src/embedding-worker.js +1 -1
  7. package/dist/src/embedding-worker.js.map +1 -1
  8. package/dist/src/index.d.ts +2 -0
  9. package/dist/src/index.js +2 -0
  10. package/dist/src/index.js.map +1 -1
  11. package/dist/src/init.js +2 -1
  12. package/dist/src/init.js.map +1 -1
  13. package/dist/src/knowledge-document.d.ts +27 -0
  14. package/dist/src/knowledge-document.js +189 -0
  15. package/dist/src/knowledge-document.js.map +1 -0
  16. package/dist/src/knowledge-governance.d.ts +22 -0
  17. package/dist/src/knowledge-governance.js +75 -0
  18. package/dist/src/knowledge-governance.js.map +1 -0
  19. package/dist/src/knowledge-sidecar.d.ts +3 -0
  20. package/dist/src/knowledge-sidecar.js +1 -0
  21. package/dist/src/knowledge-sidecar.js.map +1 -1
  22. package/dist/src/memory-query.d.ts +2 -0
  23. package/dist/src/memory-query.js +25 -4
  24. package/dist/src/memory-query.js.map +1 -1
  25. package/dist/src/memory.js +413 -61
  26. package/dist/src/memory.js.map +1 -1
  27. package/dist/src/plan-templates.d.ts +1 -0
  28. package/dist/src/plan-templates.js +61 -10
  29. package/dist/src/plan-templates.js.map +1 -1
  30. package/dist/src/project-check.js +6 -1
  31. package/dist/src/project-check.js.map +1 -1
  32. package/dist/src/project-defaults.d.ts +1 -0
  33. package/dist/src/project-defaults.js +1 -0
  34. package/dist/src/project-defaults.js.map +1 -1
  35. package/dist/src/templates/plans/default.d.ts +1 -0
  36. package/dist/src/templates/plans/default.js +1 -0
  37. package/dist/src/templates/plans/default.js.map +1 -1
  38. package/dist/src/types.d.ts +6 -0
  39. 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,7 +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";
24
- const PROJECT_EMBEDDING_CHUNKING_VERSION = "token-aware-v1";
25
+ const PROJECT_EMBEDDING_CHUNKING_VERSION = "generic-knowledge-markers-v3";
25
26
  export function buildMemoryIndex(input) {
26
27
  const { scope, project, task } = resolveMemoryScope(input);
27
28
  if (!isProjectMemoryEnabled(project)) {
@@ -452,6 +453,11 @@ function prepareSchema(db) {
452
453
  " source_path TEXT NOT NULL,",
453
454
  " kind TEXT NOT NULL,",
454
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,",
455
461
  " embedding_json TEXT NOT NULL,",
456
462
  " PRIMARY KEY (doc_id, chunk_index)",
457
463
  ");",
@@ -468,6 +474,19 @@ function prepareSchema(db) {
468
474
  if (!docsColumns.some((column) => column.name === "content_hash")) {
469
475
  db.exec("ALTER TABLE docs ADD COLUMN content_hash TEXT;");
470
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
+ }
471
490
  }
472
491
  function resolveProjectMemoryEmbeddingConfig(project) {
473
492
  if (!isProjectMemoryEnabled(project)) {
@@ -575,24 +594,29 @@ function generateDocEmbeddings(docs, embedding) {
575
594
  if (!canBuildProjectVectors(embedding)) {
576
595
  return [];
577
596
  }
578
- 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) => ({
579
598
  docId: doc.docId,
580
599
  chunkIndex,
581
600
  sourcePath: doc.sourcePath,
582
601
  kind: doc.kind,
583
- chunkText,
602
+ ...chunk,
584
603
  })));
585
604
  if (chunks.length === 0) {
586
605
  return [];
587
606
  }
588
607
  const output = runEmbeddingWorker({
589
608
  embedding,
590
- 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
+ : {}),
591
615
  splitIntoTokenWindows: embedding.provider === "local",
592
616
  });
593
617
  const segments = output.segments ?? chunks.map((chunk, sourceTextIndex) => ({
594
618
  sourceTextIndex,
595
- text: chunk.chunkText,
619
+ text: joinChunkContext(chunk.contextPrefix, chunk.bodyText),
596
620
  }));
597
621
  if (output.vectors.length !== segments.length) {
598
622
  throw new Error(`Embedding worker returned ${output.vectors.length} vectors for ${segments.length} text segments.`);
@@ -615,11 +639,11 @@ function generateDocEmbeddings(docs, embedding) {
615
639
  }
616
640
  function insertDocEmbeddings(db, embeddings) {
617
641
  const insertEmbedding = db.prepare([
618
- "INSERT OR REPLACE INTO doc_embeddings (doc_id, chunk_index, source_path, kind, chunk_text, embedding_json)",
619
- "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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
620
644
  ].join(" "));
621
645
  embeddings.forEach((embedding) => {
622
- 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));
623
647
  });
624
648
  }
625
649
  function listDocsMissingEmbeddings(db) {
@@ -672,44 +696,158 @@ function canBuildProjectVectors(embedding) {
672
696
  function hashMemoryContent(content) {
673
697
  return createHash("sha256").update(content, "utf8").digest("hex");
674
698
  }
675
- function chunkMarkdownContent(content) {
676
- const paragraphChunks = content
677
- .split(/\r?\n\s*\r?\n/g)
678
- .map((chunk) => chunk.trim())
679
- .filter((chunk) => chunk.length > 0)
680
- .flatMap((chunk) => splitOversizedMarkdownChunk(chunk));
681
- const mergedChunks = [];
682
- let current = "";
683
- for (const chunk of paragraphChunks) {
684
- if (!current) {
685
- current = chunk;
686
- 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;
687
758
  }
688
- if ((`${current}\n\n${chunk}`).length <= DEFAULT_EMBEDDING_TARGET_CHARS) {
689
- current = `${current}\n\n${chunk}`;
759
+ const bodyParagraph = bodyLines.join("\n").trim();
760
+ if (!bodyParagraph) {
690
761
  continue;
691
762
  }
692
- mergedChunks.push(current);
693
- 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
+ }
694
788
  }
695
- if (current) {
696
- 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
+ }
697
801
  }
698
- return mergedChunks;
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";
809
+ }
810
+ return value === "current" || value === "accepted" || value === "superseded" ? value : null;
811
+ }
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;
699
837
  }
700
- function splitOversizedMarkdownChunk(chunk) {
701
- if (chunk.length <= DEFAULT_EMBEDDING_MAX_CHARS) {
838
+ function splitOversizedMarkdownChunk(chunk, maxChars = DEFAULT_EMBEDDING_MAX_CHARS, targetChars = DEFAULT_EMBEDDING_TARGET_CHARS) {
839
+ if (chunk.length <= maxChars) {
702
840
  return [chunk];
703
841
  }
704
842
  const pieces = [];
705
843
  let start = 0;
706
844
  while (start < chunk.length) {
707
845
  const remaining = chunk.length - start;
708
- if (remaining <= DEFAULT_EMBEDDING_MAX_CHARS) {
846
+ if (remaining <= maxChars) {
709
847
  pieces.push(chunk.slice(start).trim());
710
848
  break;
711
849
  }
712
- 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);
713
851
  pieces.push(chunk.slice(start, preferredSplit).trim());
714
852
  start = preferredSplit;
715
853
  while (start < chunk.length && /\s/.test(chunk[start] ?? "")) {
@@ -718,8 +856,8 @@ function splitOversizedMarkdownChunk(chunk) {
718
856
  }
719
857
  return pieces.filter((piece) => piece.length > 0);
720
858
  }
721
- function findPreferredChunkBoundary(text, start, preferredEnd, hardEnd) {
722
- 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);
723
861
  for (let index = preferredEnd; index >= lowerBound; index -= 1) {
724
862
  const current = text[index];
725
863
  const previous = text[index - 1];
@@ -829,6 +967,7 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
829
967
  throw new ClawError("MEMORY_VECTOR_INDEX_REQUIRED", "Project search requires a refreshed vector index. Run `claw search index --refresh` first.");
830
968
  }
831
969
  const queryIntent = buildProjectQueryIntent(query);
970
+ const temporalIntent = detectTemporalQueryIntent(query);
832
971
  const candidateLimit = Math.max(limit * PROJECT_SEARCH_CANDIDATE_MULTIPLIER, 40);
833
972
  const projectDocs = db
834
973
  .prepare("SELECT source_path, kind, content FROM docs")
@@ -864,7 +1003,7 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
864
1003
  const queryEmbedding = resolveProjectQueryEmbedding(db, embedding, queryIntent.embeddingText || query);
865
1004
  const vectorRows = db
866
1005
  .prepare([
867
- "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",
868
1007
  "FROM doc_embeddings",
869
1008
  ].join(" "))
870
1009
  .all();
@@ -880,30 +1019,48 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
880
1019
  snippet: buildSnippet(row.chunk_text),
881
1020
  similarity: cosineSimilarity(queryEmbedding.vector, parseEmbeddingJson(row.embedding_json)),
882
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,
883
1033
  };
884
1034
  })
885
1035
  .filter((row) => Number.isFinite(row.similarity))
886
1036
  .sort((left, right) => {
887
- const leftScore = left.similarity + left.exactBoost;
888
- const rightScore = right.similarity + right.exactBoost;
1037
+ const leftScore = left.similarity + left.exactBoost + left.temporalBoost;
1038
+ const rightScore = right.similarity + right.exactBoost + right.temporalBoost;
889
1039
  return rightScore - leftScore;
890
1040
  });
891
1041
  const bestVectorBySource = new Map();
892
1042
  for (const row of rankedVectors) {
893
1043
  const existing = bestVectorBySource.get(row.sourcePath);
894
- if (!existing || row.similarity > existing.similarity) {
1044
+ const vectorScore = row.similarity + row.temporalBoost;
1045
+ if (!existing || vectorScore > existing.vectorScore) {
895
1046
  bestVectorBySource.set(row.sourcePath, {
896
1047
  kind: row.kind,
897
1048
  snippet: row.snippet,
898
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,
899
1056
  });
900
1057
  }
901
1058
  }
902
1059
  const fused = new Map();
903
1060
  Array.from(bestVectorBySource.entries())
904
1061
  .sort((left, right) => {
905
- const leftScore = left[1].similarity + (docSignals.get(left[0])?.exactBoost ?? 0);
906
- 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);
907
1064
  return rightScore - leftScore;
908
1065
  })
909
1066
  .slice(0, candidateLimit)
@@ -913,14 +1070,19 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
913
1070
  sourcePath,
914
1071
  kind: row.kind,
915
1072
  snippet: row.snippet,
916
- score: reciprocalRankScore(index + 1, 0.6) + (signals?.exactBoost ?? 0),
1073
+ score: reciprocalRankScore(index + 1, 0.5) + (signals?.exactBoost ?? 0),
917
1074
  vectorRank: index + 1,
1075
+ documentKind: row.documentKind,
1076
+ documentState: row.documentState,
1077
+ state: row.state,
1078
+ dated: row.dated,
1079
+ headingPath: row.headingPath,
918
1080
  });
919
1081
  });
920
1082
  ftsRows.forEach((row, index) => {
921
1083
  const existing = fused.get(row.source_path);
922
1084
  const signals = docSignals.get(row.source_path);
923
- const nextScore = (existing?.score ?? 0) + reciprocalRankScore(index + 1, 0.25);
1085
+ const nextScore = (existing?.score ?? 0) + reciprocalRankScore(index + 1, 0.3);
924
1086
  fused.set(row.source_path, {
925
1087
  sourcePath: row.source_path,
926
1088
  kind: existing?.kind ?? row.kind,
@@ -928,12 +1090,13 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
928
1090
  score: nextScore + (existing ? 0 : (signals?.exactBoost ?? 0)),
929
1091
  vectorRank: existing?.vectorRank,
930
1092
  textRank: index + 1,
1093
+ ...(existing ? pickTemporalResultMetadata(existing) : {}),
931
1094
  });
932
1095
  });
933
1096
  signalRows.forEach((row, index) => {
934
1097
  const existing = fused.get(row.source_path);
935
1098
  const signals = docSignals.get(row.source_path);
936
- const nextScore = (existing?.score ?? 0) + reciprocalRankScore(index + 1, 0.15);
1099
+ const nextScore = (existing?.score ?? 0) + reciprocalRankScore(index + 1, 0.2);
937
1100
  fused.set(row.source_path, {
938
1101
  sourcePath: row.source_path,
939
1102
  kind: existing?.kind ?? row.kind,
@@ -942,6 +1105,7 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
942
1105
  vectorRank: existing?.vectorRank,
943
1106
  textRank: existing?.textRank,
944
1107
  signalRank: index + 1,
1108
+ ...(existing ? pickTemporalResultMetadata(existing) : {}),
945
1109
  });
946
1110
  });
947
1111
  return {
@@ -956,21 +1120,33 @@ function searchProjectMemoryHybrid(db, query, limit, project) {
956
1120
  function tryProjectLexicalFastPath(input) {
957
1121
  const { queryIntent } = input;
958
1122
  const primaryKeywordStep = buildProjectKeywordSearchPlan(input.query)[0];
959
- if (queryIntent.strongTerms.length === 0
960
- || queryIntent.weakTerms.length > 0
961
- || !primaryKeywordStep
962
- || 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)) {
963
1137
  return null;
964
1138
  }
965
1139
  const fullCoverage = Array.from(input.docSignals.entries()).filter(([, signals]) => signals.strongCoverageRatio === 1);
966
1140
  const pathMatches = fullCoverage.filter(([, signals]) => signals.fileNameHits >= queryIntent.strongTerms.length
967
1141
  || signals.pathHits >= queryIntent.strongTerms.length);
968
1142
  const phraseMatches = fullCoverage.filter(([, signals]) => signals.phraseMatch);
969
- const confidentSourcePath = pathMatches.length === 1
970
- ? pathMatches[0]?.[0]
971
- : phraseMatches.length === 1
972
- ? phraseMatches[0]?.[0]
973
- : 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;
974
1150
  if (!confidentSourcePath) {
975
1151
  return null;
976
1152
  }
@@ -1197,6 +1373,53 @@ function matchesAllSubstrings(db, sourcePath, substringTerms) {
1197
1373
  function escapeLikePattern(term) {
1198
1374
  return term.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
1199
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
+ }
1200
1423
  function rerankProjectSearchCandidates(candidates, docSignals, limit) {
1201
1424
  const remaining = [...candidates];
1202
1425
  const selected = [];
@@ -1212,8 +1435,8 @@ function rerankProjectSearchCandidates(candidates, docSignals, limit) {
1212
1435
  const uncoveredStrongTerms = (signals?.strongMatchedTerms ?? []).filter((term) => !coveredStrongTerms.has(term));
1213
1436
  const uncoveredTerms = (signals?.matchedTerms ?? []).filter((term) => !coveredTerms.has(term));
1214
1437
  const adjustedScore = candidate.score
1215
- + uncoveredStrongTerms.length * 0.045
1216
- + uncoveredTerms.length * 0.01
1438
+ + uncoveredStrongTerms.length * 0.015
1439
+ + uncoveredTerms.length * 0.003
1217
1440
  + Math.max(routeCount - 1, 0) * 0.003;
1218
1441
  if (adjustedScore > bestScore) {
1219
1442
  bestScore = adjustedScore;
@@ -1249,6 +1472,27 @@ function rerankProjectSearchCandidates(candidates, docSignals, limit) {
1249
1472
  kind: next.kind,
1250
1473
  snippet: next.snippet,
1251
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
+ : {}),
1252
1496
  });
1253
1497
  }
1254
1498
  return selected;
@@ -1258,6 +1502,7 @@ function buildProjectSearchSignals(input) {
1258
1502
  const normalizedContent = input.content.toLowerCase();
1259
1503
  const normalizedPath = input.sourcePath.toLowerCase();
1260
1504
  const fileName = path.basename(input.sourcePath).toLowerCase();
1505
+ const title = extractDocumentTitle(input.content, fileName);
1261
1506
  const lowerTerms = input.queryIntent.terms.map((term) => term.toLowerCase());
1262
1507
  const lowerStrongTerms = input.queryIntent.strongTerms.map((term) => term.toLowerCase());
1263
1508
  const lowerWeakTerms = input.queryIntent.weakTerms.map((term) => term.toLowerCase());
@@ -1276,12 +1521,11 @@ function buildProjectSearchSignals(input) {
1276
1521
  const phraseMatch = normalizedQuery.length > 0
1277
1522
  && (normalizedContent.includes(normalizedQuery.toLowerCase()) || normalizedPath.includes(normalizedQuery.toLowerCase()));
1278
1523
  const weakOnlyPenalty = strongMatchedTerms.size === 0 && weakMatchedTerms.size > 0 ? 0.012 : 0;
1279
- const missingStrongPenalty = lowerStrongTerms.length > 0 && strongMatchedTerms.size === 0
1280
- ? 0.035
1281
- : lowerStrongTerms.length > 1 && strongMatchedTerms.size === 1
1282
- ? 0.01
1283
- : 0;
1284
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);
1285
1529
  return {
1286
1530
  matchedTerms: Array.from(matchedTerms),
1287
1531
  strongMatchedTerms: Array.from(strongMatchedTerms),
@@ -1292,6 +1536,10 @@ function buildProjectSearchSignals(input) {
1292
1536
  pathHits,
1293
1537
  phraseMatch,
1294
1538
  strongCoverageRatio,
1539
+ titleMatchScore,
1540
+ entityTitleScore,
1541
+ documentTypeScore,
1542
+ genericPenalty,
1295
1543
  exactBoost: strongMatchedTerms.size * 0.016
1296
1544
  + weakMatchedTerms.size * 0.004
1297
1545
  + coverageRatio * 0.008
@@ -1300,13 +1548,117 @@ function buildProjectSearchSignals(input) {
1300
1548
  + fileNameHits * 0.025
1301
1549
  + pathHits * 0.01
1302
1550
  + (phraseMatch ? 0.018 : 0)
1551
+ + titleMatchScore
1552
+ + entityTitleScore
1553
+ + documentTypeScore
1303
1554
  - weakOnlyPenalty
1304
- - missingStrongPenalty
1305
- - indexFilePenalty,
1555
+ - indexFilePenalty
1556
+ - genericPenalty,
1306
1557
  };
1307
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
+ }
1308
1579
  function isIndexLikeDocName(fileName) {
1309
- 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;
1310
1662
  }
1311
1663
  function reciprocalRankScore(rank, weight) {
1312
1664
  return weight * (1 / (40 + rank));