basic-memory 0.23.1.dev2__py3-none-any.whl → 0.23.1.dev20__py3-none-any.whl
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.
- basic_memory/alembic/versions/7f6a2b8c9d10_index_full_postgres_note_content.py +77 -0
- basic_memory/cli/auto_update.py +35 -3
- basic_memory/cli/commands/doctor.py +18 -4
- basic_memory/cli/commands/update.py +8 -6
- basic_memory/markdown/plugins.py +16 -3
- basic_memory/mcp/tools/write_note.py +3 -0
- basic_memory/models/search.py +29 -1
- basic_memory/repository/postgres_search_repository.py +234 -6
- {basic_memory-0.23.1.dev2.dist-info → basic_memory-0.23.1.dev20.dist-info}/METADATA +1 -1
- {basic_memory-0.23.1.dev2.dist-info → basic_memory-0.23.1.dev20.dist-info}/RECORD +13 -12
- {basic_memory-0.23.1.dev2.dist-info → basic_memory-0.23.1.dev20.dist-info}/WHEEL +0 -0
- {basic_memory-0.23.1.dev2.dist-info → basic_memory-0.23.1.dev20.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.23.1.dev2.dist-info → basic_memory-0.23.1.dev20.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Index complete PostgreSQL note content for full-text search.
|
|
2
|
+
|
|
3
|
+
Revision ID: 7f6a2b8c9d10
|
|
4
|
+
Revises: 2d26b287813b
|
|
5
|
+
Create Date: 2026-08-24 22:00:00.000000
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Sequence, Union
|
|
10
|
+
|
|
11
|
+
from alembic import op
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision: str = "7f6a2b8c9d10"
|
|
16
|
+
down_revision: Union[str, None] = "2d26b287813b"
|
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
18
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
"""Index complete note bodies as bounded PostgreSQL FTS chunks."""
|
|
23
|
+
connection = op.get_bind()
|
|
24
|
+
if connection.dialect.name == "postgresql":
|
|
25
|
+
op.execute("""
|
|
26
|
+
CREATE TABLE search_index_fts_chunks (
|
|
27
|
+
project_id INTEGER NOT NULL,
|
|
28
|
+
search_index_id INTEGER NOT NULL,
|
|
29
|
+
search_index_type VARCHAR NOT NULL,
|
|
30
|
+
chunk_index INTEGER NOT NULL,
|
|
31
|
+
chunk_text TEXT NOT NULL,
|
|
32
|
+
textsearchable_index_col tsvector GENERATED ALWAYS AS (
|
|
33
|
+
to_tsvector('english', chunk_text)
|
|
34
|
+
) STORED,
|
|
35
|
+
PRIMARY KEY (project_id, search_index_id, search_index_type, chunk_index),
|
|
36
|
+
FOREIGN KEY (search_index_id, search_index_type, project_id)
|
|
37
|
+
REFERENCES search_index(id, type, project_id)
|
|
38
|
+
ON UPDATE CASCADE ON DELETE CASCADE
|
|
39
|
+
)
|
|
40
|
+
""")
|
|
41
|
+
op.execute("""
|
|
42
|
+
-- PostgreSQL ignores lexemes at 2 KiB and above. Stepping 5,952
|
|
43
|
+
-- characters leaves a conservative 2,048-character overlap, so
|
|
44
|
+
-- every indexable lexeme split at one edge is complete in the next.
|
|
45
|
+
INSERT INTO search_index_fts_chunks (
|
|
46
|
+
project_id,
|
|
47
|
+
search_index_id,
|
|
48
|
+
search_index_type,
|
|
49
|
+
chunk_index,
|
|
50
|
+
chunk_text
|
|
51
|
+
)
|
|
52
|
+
SELECT
|
|
53
|
+
search_index.project_id,
|
|
54
|
+
search_index.id,
|
|
55
|
+
search_index.type,
|
|
56
|
+
(chunk_start - 1) / 5952,
|
|
57
|
+
substring(search_index.content_snippet FROM chunk_start FOR 8000)
|
|
58
|
+
FROM search_index
|
|
59
|
+
CROSS JOIN LATERAL generate_series(
|
|
60
|
+
1,
|
|
61
|
+
length(search_index.content_snippet),
|
|
62
|
+
5952
|
|
63
|
+
) AS chunk_start
|
|
64
|
+
WHERE search_index.content_snippet IS NOT NULL
|
|
65
|
+
AND search_index.content_snippet <> ''
|
|
66
|
+
""")
|
|
67
|
+
op.execute("""
|
|
68
|
+
CREATE INDEX idx_search_index_fts_chunks_fts
|
|
69
|
+
ON search_index_fts_chunks USING gin(textsearchable_index_col)
|
|
70
|
+
""")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def downgrade() -> None:
|
|
74
|
+
"""Remove the bounded full-content PostgreSQL FTS chunks."""
|
|
75
|
+
connection = op.get_bind()
|
|
76
|
+
if connection.dialect.name == "postgresql":
|
|
77
|
+
op.execute("DROP TABLE IF EXISTS search_index_fts_chunks")
|
basic_memory/cli/auto_update.py
CHANGED
|
@@ -221,6 +221,38 @@ def _preload_lazy_console_modules() -> None:
|
|
|
221
221
|
"""
|
|
222
222
|
import rich._emoji_codes # noqa: F401
|
|
223
223
|
import typer.rich_utils # noqa: F401
|
|
224
|
+
from rich.cells import cell_len
|
|
225
|
+
|
|
226
|
+
# Trigger: rich defers its Unicode cell-width table (`rich._unicode_data.
|
|
227
|
+
# unicode<version>`) until the first character it cannot measure with the
|
|
228
|
+
# ASCII fast path in `_cell_len`.
|
|
229
|
+
# Why: status messages echo captured `brew`/`uv` output, which carries
|
|
230
|
+
# non-ASCII characters (curly quotes, em dashes, warning glyphs), so the
|
|
231
|
+
# deferred import lands after the upgrade removed our files. Importing the
|
|
232
|
+
# module by name would hard-code a table version; calling `cell_len` uses
|
|
233
|
+
# rich's own resolution and honors UNICODE_VERSION like the print path does.
|
|
234
|
+
# Outcome: the table rich will reach for is resolved and cached up front.
|
|
235
|
+
cell_len("\u2500\u2018\u2713")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def print_update_status(console: Console, text: str, style: str) -> None:
|
|
239
|
+
"""Print an update status line that cannot fail the command.
|
|
240
|
+
|
|
241
|
+
Trigger: the line is printed after an in-place upgrade may already have
|
|
242
|
+
replaced this installation on disk.
|
|
243
|
+
Why: `_preload_lazy_console_modules` can only preload the deferred imports
|
|
244
|
+
we know about today, and rich/typer are free to add more. By the time this
|
|
245
|
+
prints, the upgrade has already succeeded -- a status line must never be
|
|
246
|
+
what turns it into a traceback and a non-zero exit.
|
|
247
|
+
Outcome: fall back to a plain, unstyled write that needs no new imports.
|
|
248
|
+
"""
|
|
249
|
+
try:
|
|
250
|
+
console.print(f"[{style}]{text}[/{style}]")
|
|
251
|
+
except Exception as exc:
|
|
252
|
+
logger.warning(
|
|
253
|
+
f"Rich console print failed after update, falling back to plain output: {exc}"
|
|
254
|
+
)
|
|
255
|
+
print(text)
|
|
224
256
|
|
|
225
257
|
|
|
226
258
|
def _save_last_checked_timestamp(config_manager: ConfigManager, checked_at: datetime) -> None:
|
|
@@ -468,11 +500,11 @@ def maybe_run_periodic_auto_update(
|
|
|
468
500
|
}:
|
|
469
501
|
out = console or Console()
|
|
470
502
|
if result.status == AutoUpdateStatus.UPDATED:
|
|
471
|
-
out
|
|
503
|
+
print_update_status(out, f"{result.message}", "green")
|
|
472
504
|
elif result.status == AutoUpdateStatus.FAILED:
|
|
473
505
|
error_detail = f" {result.error}" if result.error else ""
|
|
474
|
-
out
|
|
506
|
+
print_update_status(out, f"{result.message}{error_detail}", "yellow")
|
|
475
507
|
elif result.message:
|
|
476
|
-
out
|
|
508
|
+
print_update_status(out, f"{result.message}", "cyan")
|
|
477
509
|
|
|
478
510
|
return result
|
|
@@ -81,6 +81,23 @@ async def _delete_doctor_project(
|
|
|
81
81
|
await _delete_doctor_project_locally(project_name, project_id)
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
async def _read_materialized_api_note(api_file: Path, file_path: str) -> str:
|
|
85
|
+
"""Wait for the accepted local write, then read its canonical file."""
|
|
86
|
+
# Deferred: importing the materialization runtime at CLI module load would slow every
|
|
87
|
+
# command, while doctor is the only command that needs to observe its write immediately.
|
|
88
|
+
from basic_memory.index.note_content_materialization import drain_pending_materializations
|
|
89
|
+
|
|
90
|
+
# Trigger: production accepts note content before its markdown file is materialized.
|
|
91
|
+
# Why: doctor verifies the complete DB -> file contract, not only write acceptance.
|
|
92
|
+
# Outcome: wait for the same deferred work drained at normal CLI shutdown before checking.
|
|
93
|
+
await drain_pending_materializations()
|
|
94
|
+
|
|
95
|
+
if not api_file.exists():
|
|
96
|
+
raise ValueError(f"API note file missing: {file_path}")
|
|
97
|
+
|
|
98
|
+
return api_file.read_text(encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
|
|
84
101
|
async def run_doctor() -> None:
|
|
85
102
|
"""Run local consistency checks for file <-> database flows."""
|
|
86
103
|
# Deferred: the markdown parsing stack is only needed while the checks run,
|
|
@@ -132,10 +149,7 @@ async def run_doctor() -> None:
|
|
|
132
149
|
api_result = await knowledge_client.create_entity(api_note.model_dump())
|
|
133
150
|
|
|
134
151
|
api_file = project_path / api_result.file_path
|
|
135
|
-
|
|
136
|
-
raise ValueError(f"API note file missing: {api_result.file_path}")
|
|
137
|
-
|
|
138
|
-
api_text = api_file.read_text(encoding="utf-8")
|
|
152
|
+
api_text = await _read_materialized_api_note(api_file, api_result.file_path)
|
|
139
153
|
if api_note_title not in api_text:
|
|
140
154
|
raise ValueError("API note content missing from file")
|
|
141
155
|
|
|
@@ -4,7 +4,7 @@ import typer
|
|
|
4
4
|
from rich.console import Console
|
|
5
5
|
|
|
6
6
|
from basic_memory.cli.app import app
|
|
7
|
-
from basic_memory.cli.auto_update import AutoUpdateStatus, run_auto_update
|
|
7
|
+
from basic_memory.cli.auto_update import AutoUpdateStatus, print_update_status, run_auto_update
|
|
8
8
|
|
|
9
9
|
console = Console()
|
|
10
10
|
|
|
@@ -22,19 +22,21 @@ def update(
|
|
|
22
22
|
|
|
23
23
|
if result.status == AutoUpdateStatus.FAILED:
|
|
24
24
|
detail = f" {result.error}" if result.error else ""
|
|
25
|
-
console
|
|
25
|
+
print_update_status(console, f"{result.message or 'Update failed.'}{detail}", "red")
|
|
26
26
|
raise typer.Exit(1)
|
|
27
27
|
|
|
28
28
|
if result.status == AutoUpdateStatus.UPDATED:
|
|
29
|
-
|
|
29
|
+
print_update_status(
|
|
30
|
+
console, f"{result.message or 'Basic Memory updated successfully.'}", "green"
|
|
31
|
+
)
|
|
30
32
|
return
|
|
31
33
|
|
|
32
34
|
if result.status == AutoUpdateStatus.UP_TO_DATE:
|
|
33
|
-
console
|
|
35
|
+
print_update_status(console, f"{result.message or 'Basic Memory is up to date.'}", "green")
|
|
34
36
|
return
|
|
35
37
|
|
|
36
38
|
if result.status == AutoUpdateStatus.UPDATE_AVAILABLE:
|
|
37
|
-
console
|
|
39
|
+
print_update_status(console, f"{result.message or 'Update available.'}", "cyan")
|
|
38
40
|
return
|
|
39
41
|
|
|
40
|
-
console
|
|
42
|
+
print_update_status(console, f"{result.message or 'No update action was performed.'}", "dim")
|
basic_memory/markdown/plugins.py
CHANGED
|
@@ -13,6 +13,15 @@ from markdown_it.token import Token
|
|
|
13
13
|
# those bracket prefixes stay ordinary content (issue #1219).
|
|
14
14
|
_TIMESTAMP_VALUE = r"\d{1,3}:\d{2}(?::\d{2})?(?:[.,]\d{1,3})?"
|
|
15
15
|
_TIMESTAMP_CATEGORY = re.compile(rf"^{_TIMESTAMP_VALUE}(?:\s+-\s+{_TIMESTAMP_VALUE})?$")
|
|
16
|
+
_LINKS_TO_DIRECTIVE = re.compile(r"\s+#bm:links_to\s*$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def remove_links_to_directive(content: str) -> tuple[str, bool]:
|
|
20
|
+
"""Remove an exact terminal ``#bm:links_to`` directive from content."""
|
|
21
|
+
match = _LINKS_TO_DIRECTIVE.search(content)
|
|
22
|
+
if not match:
|
|
23
|
+
return content, False
|
|
24
|
+
return content[: match.start()].rstrip(), True
|
|
16
25
|
|
|
17
26
|
|
|
18
27
|
def _is_task_marker_category(category: str) -> bool:
|
|
@@ -47,6 +56,7 @@ def is_observation(token: Token) -> bool:
|
|
|
47
56
|
return False
|
|
48
57
|
# Use token.tag which contains the actual content for test tokens, fallback to content
|
|
49
58
|
content = (token.tag or token.content).strip()
|
|
59
|
+
content, _ = remove_links_to_directive(content)
|
|
50
60
|
if not content: # pragma: no cover
|
|
51
61
|
return False
|
|
52
62
|
# if it's a markdown_task, return false
|
|
@@ -74,6 +84,7 @@ def parse_observation(token: Token) -> Dict[str, Any]:
|
|
|
74
84
|
|
|
75
85
|
# Use token.tag which contains the actual content for test tokens, fallback to content
|
|
76
86
|
content = (token.tag or token.content).strip()
|
|
87
|
+
content, _ = remove_links_to_directive(content)
|
|
77
88
|
|
|
78
89
|
# Parse [category] with regex; a timestamp-shaped prefix is not a category, so a
|
|
79
90
|
# hashtag-promoted transcript line keeps its timecode inside the content instead.
|
|
@@ -325,17 +336,19 @@ def relation_plugin(md: MarkdownIt) -> None:
|
|
|
325
336
|
|
|
326
337
|
# Only process inline tokens
|
|
327
338
|
if token.type == "inline":
|
|
339
|
+
content = token.tag or token.content
|
|
340
|
+
content_without_directive, has_directive = remove_links_to_directive(content)
|
|
341
|
+
|
|
328
342
|
# Check for explicit relations in list items
|
|
329
|
-
if in_list_item and is_explicit_relation(token):
|
|
343
|
+
if in_list_item and not has_directive and is_explicit_relation(token):
|
|
330
344
|
rel = parse_relation(token)
|
|
331
345
|
if rel:
|
|
332
346
|
token.meta["relations"] = [rel]
|
|
333
347
|
|
|
334
348
|
# Always check for inline links in any text
|
|
335
349
|
else:
|
|
336
|
-
content = token.tag or token.content
|
|
337
350
|
if "[[" in content:
|
|
338
|
-
rels = parse_inline_relations(
|
|
351
|
+
rels = parse_inline_relations(content_without_directive)
|
|
339
352
|
if rels:
|
|
340
353
|
token.meta["relations"] = token.meta.get("relations", []) + rels
|
|
341
354
|
|
|
@@ -109,12 +109,15 @@ async def write_note(
|
|
|
109
109
|
- Explicit: `- relation_type [[Entity]] (optional context)`
|
|
110
110
|
- Quoted: `- "multi word relation type" [[Entity]] (optional context)`
|
|
111
111
|
- Quoted: `- 'multi word relation type' [[Entity]] (optional context)`
|
|
112
|
+
- Disambiguation: Add `#bm:links_to` when prose before `[[Entity]]`
|
|
113
|
+
must not be treated as a single-token relation type
|
|
112
114
|
- Inline: Any other `[[Entity]]` reference creates a `links_to` relation
|
|
113
115
|
|
|
114
116
|
Examples:
|
|
115
117
|
`- depends_on [[Content Parser]] (Need for semantic extraction)`
|
|
116
118
|
`- "based on" [[Design Notes]]`
|
|
117
119
|
`- 'in response to' [[Incident Review]]`
|
|
120
|
+
`- Mother [[Alice]] #bm:links_to`
|
|
118
121
|
`- implements [[Search Spec]] (Initial implementation)`
|
|
119
122
|
`- This feature extends [[Base Design]] and uses [[Core Utils]]`
|
|
120
123
|
|
basic_memory/models/search.py
CHANGED
|
@@ -33,7 +33,11 @@ CREATE TABLE IF NOT EXISTS search_index (
|
|
|
33
33
|
created_at TIMESTAMP WITH TIME ZONE,
|
|
34
34
|
updated_at TIMESTAMP WITH TIME ZONE,
|
|
35
35
|
textsearchable_index_col tsvector GENERATED ALWAYS AS (
|
|
36
|
-
to_tsvector(
|
|
36
|
+
to_tsvector(
|
|
37
|
+
'english',
|
|
38
|
+
coalesce(title, '') || ' ' ||
|
|
39
|
+
coalesce(content_stems, '')
|
|
40
|
+
)
|
|
37
41
|
) STORED,
|
|
38
42
|
PRIMARY KEY (id, type, project_id),
|
|
39
43
|
FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
|
|
@@ -44,6 +48,30 @@ CREATE_POSTGRES_SEARCH_INDEX_FTS = DDL("""
|
|
|
44
48
|
CREATE INDEX IF NOT EXISTS idx_search_index_fts ON search_index USING gin(textsearchable_index_col)
|
|
45
49
|
""")
|
|
46
50
|
|
|
51
|
+
# Full note bodies are stored in bounded child rows so one unusually large note
|
|
52
|
+
# cannot exceed PostgreSQL's per-tsvector size limit.
|
|
53
|
+
CREATE_POSTGRES_SEARCH_INDEX_FTS_CHUNKS_TABLE = DDL("""
|
|
54
|
+
CREATE TABLE IF NOT EXISTS search_index_fts_chunks (
|
|
55
|
+
project_id INTEGER NOT NULL,
|
|
56
|
+
search_index_id INTEGER NOT NULL,
|
|
57
|
+
search_index_type VARCHAR NOT NULL,
|
|
58
|
+
chunk_index INTEGER NOT NULL,
|
|
59
|
+
chunk_text TEXT NOT NULL,
|
|
60
|
+
textsearchable_index_col tsvector GENERATED ALWAYS AS (
|
|
61
|
+
to_tsvector('english', chunk_text)
|
|
62
|
+
) STORED,
|
|
63
|
+
PRIMARY KEY (project_id, search_index_id, search_index_type, chunk_index),
|
|
64
|
+
FOREIGN KEY (search_index_id, search_index_type, project_id)
|
|
65
|
+
REFERENCES search_index(id, type, project_id)
|
|
66
|
+
ON UPDATE CASCADE ON DELETE CASCADE
|
|
67
|
+
)
|
|
68
|
+
""")
|
|
69
|
+
|
|
70
|
+
CREATE_POSTGRES_SEARCH_INDEX_FTS_CHUNKS_INDEX = DDL("""
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_search_index_fts_chunks_fts
|
|
72
|
+
ON search_index_fts_chunks USING gin(textsearchable_index_col)
|
|
73
|
+
""")
|
|
74
|
+
|
|
47
75
|
CREATE_POSTGRES_SEARCH_INDEX_METADATA = DDL("""
|
|
48
76
|
CREATE INDEX IF NOT EXISTS idx_search_index_metadata_gin ON search_index USING gin(metadata jsonb_path_ops)
|
|
49
77
|
""")
|
|
@@ -45,6 +45,66 @@ from basic_memory.repository.pgvector_index import PgVectorIndex
|
|
|
45
45
|
from basic_memory.schemas.search import SearchItemType, SearchRetrievalMode
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
POSTGRES_FTS_CHUNK_SIZE = 8_000
|
|
49
|
+
# PostgreSQL ignores lexemes at 2 KiB and above. A 2,048-character overlap is
|
|
50
|
+
# therefore conservative for every indexable lexeme, including multi-byte text:
|
|
51
|
+
# any token split at one 8,000-character edge is complete in the next chunk.
|
|
52
|
+
POSTGRES_FTS_CHUNK_OVERLAP = 2_048
|
|
53
|
+
_TSQUERY_OPERAND_PATTERN = re.compile(r"'(?:''|[^'])*'(?::\*)?|[^\s&|!()]+")
|
|
54
|
+
_TSQUERY_WORD_PATTERN = re.compile(r"[^\W_]+(?:'[^\W_]+)?", re.UNICODE)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _iter_fts_chunks(content: str | None) -> list[tuple[int, str]]:
|
|
58
|
+
"""Split full note text without losing an indexable lexeme at a chunk edge."""
|
|
59
|
+
if not content:
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
step = POSTGRES_FTS_CHUNK_SIZE - POSTGRES_FTS_CHUNK_OVERLAP
|
|
63
|
+
return [
|
|
64
|
+
(chunk_index, content[start : start + POSTGRES_FTS_CHUNK_SIZE])
|
|
65
|
+
for chunk_index, start in enumerate(range(0, len(content), step))
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _tsquery_operands(processed_text: str) -> list[tuple[str, str]]:
|
|
70
|
+
"""Return unique (query operand, representative text) pairs in source order."""
|
|
71
|
+
operands: dict[str, str] = {}
|
|
72
|
+
for operand in _TSQUERY_OPERAND_PATTERN.findall(processed_text):
|
|
73
|
+
representative = operand.removesuffix(":*")
|
|
74
|
+
if representative.startswith("'") and representative.endswith("'"):
|
|
75
|
+
representative = representative[1:-1].replace("''", "'")
|
|
76
|
+
operands.setdefault(operand, representative)
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
# An unquoted apostrophe is invalid tsquery syntax. Keep the literal
|
|
80
|
+
# word for the synthetic document, but quote and escape its probe so a
|
|
81
|
+
# strict syntax failure can proceed to the relaxed retry.
|
|
82
|
+
if "'" in representative:
|
|
83
|
+
escaped = "'{}'".format(representative.replace("'", "''"))
|
|
84
|
+
safe_operand = f"{escaped}:*" if operand.endswith(":*") else escaped
|
|
85
|
+
operands.setdefault(safe_operand, representative)
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# PostgreSQL legitimately parses punctuation inside operands such as
|
|
89
|
+
# ``v0.13.0b2:*`` and ``auth-service:*``. Preserve those bytes so the
|
|
90
|
+
# synthetic document is tokenized the same way as the original note.
|
|
91
|
+
if "<" not in representative and ">" not in representative:
|
|
92
|
+
operands.setdefault(operand, representative)
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
# A malformed strict operand (for example ``foo<bar:*``) must not poison
|
|
96
|
+
# the later relaxed retry. Split punctuation into the same safe word pieces
|
|
97
|
+
# that PostgreSQL will lex, while the original full tsquery still determines
|
|
98
|
+
# whether the strict attempt raises and falls back.
|
|
99
|
+
is_prefix = operand.endswith(":*")
|
|
100
|
+
words = _TSQUERY_WORD_PATTERN.findall(representative)
|
|
101
|
+
for word in words or [representative]:
|
|
102
|
+
escaped_word = "'{}'".format(word.replace("'", "''")) if "'" in word else word
|
|
103
|
+
safe_operand = f"{escaped_word}:*" if is_prefix else escaped_word
|
|
104
|
+
operands.setdefault(safe_operand, word)
|
|
105
|
+
return list(operands.items())
|
|
106
|
+
|
|
107
|
+
|
|
48
108
|
def _strip_nul_from_row(row_data: dict[str, Any]) -> dict[str, Any]:
|
|
49
109
|
"""Strip NUL bytes from all string values in a row dict.
|
|
50
110
|
|
|
@@ -199,9 +259,76 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
|
|
199
259
|
"""),
|
|
200
260
|
insert_data,
|
|
201
261
|
)
|
|
262
|
+
await self._replace_fts_chunks(session, [search_index_row])
|
|
202
263
|
logger.debug(f"indexed row {search_index_row}")
|
|
203
264
|
await session.commit()
|
|
204
265
|
|
|
266
|
+
async def _replace_fts_chunks(
|
|
267
|
+
self,
|
|
268
|
+
session: AsyncSession,
|
|
269
|
+
search_index_rows: Sequence[SearchIndexRow],
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Replace bounded full-content FTS rows for one indexing batch."""
|
|
272
|
+
if not search_index_rows:
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
await session.execute(
|
|
276
|
+
text("""
|
|
277
|
+
DELETE FROM search_index_fts_chunks
|
|
278
|
+
WHERE project_id = :project_id
|
|
279
|
+
AND (search_index_id, search_index_type) IN (
|
|
280
|
+
SELECT search_index_id, search_index_type
|
|
281
|
+
FROM unnest(
|
|
282
|
+
CAST(:search_index_ids AS INTEGER[]),
|
|
283
|
+
CAST(:search_index_types AS VARCHAR[])
|
|
284
|
+
) AS indexed_rows(search_index_id, search_index_type)
|
|
285
|
+
)
|
|
286
|
+
"""),
|
|
287
|
+
{
|
|
288
|
+
"project_id": self.project_id,
|
|
289
|
+
"search_index_ids": [row.id for row in search_index_rows],
|
|
290
|
+
"search_index_types": [row.type for row in search_index_rows],
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
chunks = [
|
|
295
|
+
{
|
|
296
|
+
"search_index_id": row.id,
|
|
297
|
+
"search_index_type": row.type,
|
|
298
|
+
"chunk_index": chunk_index,
|
|
299
|
+
"chunk_text": chunk_text.replace("\x00", ""),
|
|
300
|
+
}
|
|
301
|
+
for row in search_index_rows
|
|
302
|
+
for chunk_index, chunk_text in _iter_fts_chunks(row.content_snippet)
|
|
303
|
+
]
|
|
304
|
+
if not chunks:
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
await session.execute(
|
|
308
|
+
text("""
|
|
309
|
+
INSERT INTO search_index_fts_chunks (
|
|
310
|
+
project_id,
|
|
311
|
+
search_index_id,
|
|
312
|
+
search_index_type,
|
|
313
|
+
chunk_index,
|
|
314
|
+
chunk_text
|
|
315
|
+
)
|
|
316
|
+
SELECT
|
|
317
|
+
:project_id,
|
|
318
|
+
chunk.search_index_id,
|
|
319
|
+
chunk.search_index_type,
|
|
320
|
+
chunk.chunk_index,
|
|
321
|
+
chunk.chunk_text
|
|
322
|
+
FROM jsonb_to_recordset(CAST(:chunks AS JSONB)) AS chunk(
|
|
323
|
+
search_index_id INTEGER,
|
|
324
|
+
search_index_type VARCHAR,
|
|
325
|
+
chunk_index INTEGER,
|
|
326
|
+
chunk_text TEXT
|
|
327
|
+
)
|
|
328
|
+
"""),
|
|
329
|
+
{"project_id": self.project_id, "chunks": json.dumps(chunks)},
|
|
330
|
+
)
|
|
331
|
+
|
|
205
332
|
# ------------------------------------------------------------------
|
|
206
333
|
# tsquery preparation (backend-specific)
|
|
207
334
|
# ------------------------------------------------------------------
|
|
@@ -657,6 +784,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
|
|
657
784
|
"""),
|
|
658
785
|
insert_data_list,
|
|
659
786
|
)
|
|
787
|
+
await self._replace_fts_chunks(session, search_index_rows)
|
|
660
788
|
logger.debug(f"Bulk indexed {len(search_index_rows)} rows")
|
|
661
789
|
await session.commit()
|
|
662
790
|
|
|
@@ -685,12 +813,14 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
|
|
685
813
|
search_item_types: Optional[List[SearchItemType]] = None,
|
|
686
814
|
categories: Optional[List[str]] = None,
|
|
687
815
|
metadata_filters: Optional[dict[str, Any]] = None,
|
|
816
|
+
allow_relaxed: bool = False,
|
|
688
817
|
) -> tuple[str, str, dict[str, Any], str, str]:
|
|
689
818
|
"""Build Postgres FTS FROM/WHERE params shared by search and count."""
|
|
690
819
|
conditions = []
|
|
691
820
|
params = {}
|
|
692
821
|
order_by_clause = ""
|
|
693
822
|
from_clause = "search_index"
|
|
823
|
+
document_vector_sql: str | None = None
|
|
694
824
|
|
|
695
825
|
# Handle text search for title and content using tsvector
|
|
696
826
|
if search_text:
|
|
@@ -701,10 +831,61 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
|
|
701
831
|
# Prepare search term for tsquery
|
|
702
832
|
processed_text = self._prepare_search_term(search_text.strip())
|
|
703
833
|
params["text"] = processed_text
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
834
|
+
probe_texts = [processed_text]
|
|
835
|
+
if allow_relaxed:
|
|
836
|
+
relaxed_text = self._relaxed_tsquery_text(search_text)
|
|
837
|
+
if relaxed_text:
|
|
838
|
+
probe_texts.append(relaxed_text)
|
|
839
|
+
|
|
840
|
+
candidate_operands: dict[str, None] = {}
|
|
841
|
+
for probe_text in probe_texts:
|
|
842
|
+
for operand, _representative in _tsquery_operands(probe_text):
|
|
843
|
+
candidate_operands.setdefault(operand, None)
|
|
844
|
+
if candidate_operands:
|
|
845
|
+
params["text_candidate"] = " | ".join(candidate_operands)
|
|
846
|
+
|
|
847
|
+
# Trigger: PostgreSQL can extract a required-positive query tree.
|
|
848
|
+
# Why: OR-ing its operands is a safe indexed superset even when
|
|
849
|
+
# terms live in different chunks. Pure/optional negation returns
|
|
850
|
+
# ``T`` and must retain all project rows for correct semantics.
|
|
851
|
+
# Outcome: ordinary and required-positive NOT queries use both
|
|
852
|
+
# GIN indexes; only genuinely unindexable negation scans the project.
|
|
853
|
+
from_clause = """
|
|
854
|
+
search_index JOIN (
|
|
855
|
+
SELECT
|
|
856
|
+
candidate_parent.project_id,
|
|
857
|
+
candidate_parent.id,
|
|
858
|
+
candidate_parent.type
|
|
859
|
+
FROM search_index AS candidate_parent
|
|
860
|
+
WHERE candidate_parent.project_id = :project_id
|
|
861
|
+
AND querytree(to_tsquery('english', :text)) <> 'T'
|
|
862
|
+
AND candidate_parent.textsearchable_index_col
|
|
863
|
+
@@ to_tsquery('english', :text_candidate)
|
|
864
|
+
UNION
|
|
865
|
+
SELECT
|
|
866
|
+
candidate_chunk.project_id,
|
|
867
|
+
candidate_chunk.search_index_id AS id,
|
|
868
|
+
candidate_chunk.search_index_type AS type
|
|
869
|
+
FROM search_index_fts_chunks AS candidate_chunk
|
|
870
|
+
WHERE candidate_chunk.project_id = :project_id
|
|
871
|
+
AND querytree(to_tsquery('english', :text)) <> 'T'
|
|
872
|
+
AND candidate_chunk.textsearchable_index_col
|
|
873
|
+
@@ to_tsquery('english', :text_candidate)
|
|
874
|
+
UNION
|
|
875
|
+
SELECT
|
|
876
|
+
candidate_all.project_id,
|
|
877
|
+
candidate_all.id,
|
|
878
|
+
candidate_all.type
|
|
879
|
+
FROM search_index AS candidate_all
|
|
880
|
+
WHERE candidate_all.project_id = :project_id
|
|
881
|
+
AND querytree(to_tsquery('english', :text)) = 'T'
|
|
882
|
+
) AS fts_candidate
|
|
883
|
+
ON fts_candidate.project_id = search_index.project_id
|
|
884
|
+
AND fts_candidate.id = search_index.id
|
|
885
|
+
AND fts_candidate.type = search_index.type
|
|
886
|
+
"""
|
|
887
|
+
document_vector_sql = self._document_fts_vector_sql(probe_texts, params)
|
|
888
|
+
conditions.append(f"{document_vector_sql} @@ to_tsquery('english', :text)")
|
|
708
889
|
|
|
709
890
|
# Handle title search
|
|
710
891
|
if title:
|
|
@@ -785,7 +966,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
|
|
785
966
|
# path parts instead of #>> / #> with interpolated paths.
|
|
786
967
|
if metadata_filters:
|
|
787
968
|
parsed_filters = parse_metadata_filters(metadata_filters)
|
|
788
|
-
from_clause = "
|
|
969
|
+
from_clause = f"{from_clause} JOIN entity ON search_index.entity_id = entity.id"
|
|
789
970
|
metadata_expr = "entity.entity_metadata::jsonb"
|
|
790
971
|
|
|
791
972
|
for idx, filt in enumerate(parsed_filters):
|
|
@@ -863,14 +1044,59 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
|
|
863
1044
|
# Build SQL with ts_rank() for scoring
|
|
864
1045
|
# Note: If no text search, score will be NULL, so we use COALESCE to default to 0
|
|
865
1046
|
if search_text and search_text.strip() and search_text.strip() != "*":
|
|
1047
|
+
assert document_vector_sql is not None
|
|
866
1048
|
score_expr = (
|
|
867
|
-
"
|
|
1049
|
+
"GREATEST("
|
|
1050
|
+
f"ts_rank({document_vector_sql}, to_tsquery('english', :text)), "
|
|
1051
|
+
"ts_rank(search_index.textsearchable_index_col, to_tsquery('english', :text)), "
|
|
1052
|
+
"COALESCE((SELECT MAX(ts_rank("
|
|
1053
|
+
"fts_chunk.textsearchable_index_col, to_tsquery('english', :text))) "
|
|
1054
|
+
"FROM search_index_fts_chunks AS fts_chunk "
|
|
1055
|
+
"WHERE fts_chunk.project_id = search_index.project_id "
|
|
1056
|
+
"AND fts_chunk.search_index_id = search_index.id "
|
|
1057
|
+
"AND fts_chunk.search_index_type = search_index.type "
|
|
1058
|
+
"AND fts_chunk.textsearchable_index_col "
|
|
1059
|
+
"@@ to_tsquery('english', :text)), 0))"
|
|
868
1060
|
)
|
|
869
1061
|
else:
|
|
870
1062
|
score_expr = "0"
|
|
871
1063
|
|
|
872
1064
|
return from_clause, where_clause, params, order_by_clause, score_expr
|
|
873
1065
|
|
|
1066
|
+
@staticmethod
|
|
1067
|
+
def _document_fts_vector_sql(processed_texts: Sequence[str], params: dict[str, Any]) -> str:
|
|
1068
|
+
"""Build a query-sized vector representing lexemes found anywhere in one item."""
|
|
1069
|
+
operands: dict[str, str] = {}
|
|
1070
|
+
for processed_text in processed_texts:
|
|
1071
|
+
for operand, representative in _tsquery_operands(processed_text):
|
|
1072
|
+
operands.setdefault(operand, representative)
|
|
1073
|
+
|
|
1074
|
+
present_lexemes: list[str] = []
|
|
1075
|
+
for index, (operand, representative) in enumerate(operands.items()):
|
|
1076
|
+
operand_param = f"text_operand_{index}"
|
|
1077
|
+
representative_param = f"text_representative_{index}"
|
|
1078
|
+
params[operand_param] = operand
|
|
1079
|
+
params[representative_param] = representative
|
|
1080
|
+
present_lexemes.append(
|
|
1081
|
+
"CASE WHEN (search_index.textsearchable_index_col "
|
|
1082
|
+
f"@@ to_tsquery('english', :{operand_param}) OR EXISTS ("
|
|
1083
|
+
"SELECT 1 FROM search_index_fts_chunks AS operand_chunk "
|
|
1084
|
+
"WHERE operand_chunk.project_id = search_index.project_id "
|
|
1085
|
+
"AND operand_chunk.search_index_id = search_index.id "
|
|
1086
|
+
"AND operand_chunk.search_index_type = search_index.type "
|
|
1087
|
+
"AND operand_chunk.textsearchable_index_col "
|
|
1088
|
+
f"@@ to_tsquery('english', :{operand_param}))) "
|
|
1089
|
+
f"THEN :{representative_param} ELSE '' END"
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
if not present_lexemes:
|
|
1093
|
+
return "search_index.textsearchable_index_col"
|
|
1094
|
+
|
|
1095
|
+
# The synthesized text contains at most the query operands, never the note body.
|
|
1096
|
+
# This preserves document-wide Boolean semantics without recreating an unbounded vector.
|
|
1097
|
+
lexeme_array = f"ARRAY[{', '.join(present_lexemes)}]"
|
|
1098
|
+
return f"to_tsvector('english', array_to_string({lexeme_array}, ' '))"
|
|
1099
|
+
|
|
874
1100
|
@override
|
|
875
1101
|
async def search(
|
|
876
1102
|
self,
|
|
@@ -930,6 +1156,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
|
|
930
1156
|
search_item_types=search_item_types,
|
|
931
1157
|
categories=categories,
|
|
932
1158
|
metadata_filters=metadata_filters,
|
|
1159
|
+
allow_relaxed=allow_relaxed,
|
|
933
1160
|
)
|
|
934
1161
|
|
|
935
1162
|
# set limit and offset
|
|
@@ -1111,6 +1338,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
|
|
1111
1338
|
search_item_types=search_item_types,
|
|
1112
1339
|
categories=categories,
|
|
1113
1340
|
metadata_filters=metadata_filters,
|
|
1341
|
+
allow_relaxed=allow_relaxed,
|
|
1114
1342
|
)
|
|
1115
1343
|
sql = f"SELECT COUNT(*) FROM {from_clause} WHERE {where_clause}"
|
|
1116
1344
|
logger.trace(f"Count {sql} params: {params}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: basic-memory
|
|
3
|
-
Version: 0.23.1.
|
|
3
|
+
Version: 0.23.1.dev20
|
|
4
4
|
Summary: Local-first knowledge management combining Zettelkasten with knowledge graphs
|
|
5
5
|
Project-URL: Homepage, https://github.com/basicmachines-co/basic-memory
|
|
6
6
|
Project-URL: Repository, https://github.com/basicmachines-co/basic-memory
|
|
@@ -23,6 +23,7 @@ basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink
|
|
|
23
23
|
basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py,sha256=xTSVvB-caYl_CdVfzhj-eqdQEOxQ12NIHOc8dBSqCMQ,4779
|
|
24
24
|
basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py,sha256=oFtjzJd37HUMezuCUonK-yRc867_9L5zxd4CnLhFIi4,4655
|
|
25
25
|
basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py,sha256=8bO4yg_v6klCJz0E15sDI2pDtsmAL2nNO5ACpKGLYQo,499
|
|
26
|
+
basic_memory/alembic/versions/7f6a2b8c9d10_index_full_postgres_note_content.py,sha256=Kf4-_uBKe28MbpbKPsxZEKQzCYpIxv79iWxbKUZI91w,2857
|
|
26
27
|
basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py,sha256=DYlXlRYO3Ms6x9GbrFGWgj1QEzsdKHx-4AA4lSVM7y0,1587
|
|
27
28
|
basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py,sha256=i42eQyO08RWf4exnH1q1PKw_EJFsHAG-T4pdLTMSpiA,1818
|
|
28
29
|
basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py,sha256=Oh_J5Jw85J3LvknfizFP7_1SoelZM_A7IjBau8lRpfk,1671
|
|
@@ -67,7 +68,7 @@ basic_memory/cli/__init__.py,sha256=arcKLAWRDhPD7x5t80MlviZeYzwHZ0GZigyy3NKVoGk,
|
|
|
67
68
|
basic_memory/cli/analytics.py,sha256=GlyEFzvTRh2A8E1iFzRVLYVMn1_torhcbptfKNYU59w,3858
|
|
68
69
|
basic_memory/cli/app.py,sha256=Tsz6bcWDWJWDBqdPnvEOQrUWG2UcdWriradzWj04r50,6318
|
|
69
70
|
basic_memory/cli/auth.py,sha256=tQSE3uHhGPTfxkCcJ1FR2HDkmyu_E9uoaxHUENFUhAA,11523
|
|
70
|
-
basic_memory/cli/auto_update.py,sha256=
|
|
71
|
+
basic_memory/cli/auto_update.py,sha256=tYgJ8wjIuEwHGg4lh3UwMjFTija6_58GHAjJl1YeHr4,19690
|
|
71
72
|
basic_memory/cli/container.py,sha256=5oIdLTEBsVNl_3q1blh50TSThCO_tEpDaO-cXITeQnk,2299
|
|
72
73
|
basic_memory/cli/main.py,sha256=LF5u9iyvGDnn8DsfORzQOWVXFR0-TL2HHqntB2b8MFI,1160
|
|
73
74
|
basic_memory/cli/promo.py,sha256=c_CxzCifl_1ZHPT7-Kd84TLG1Gt7sfXXmCmRYSTCerM,4592
|
|
@@ -76,7 +77,7 @@ basic_memory/cli/commands/ci.py,sha256=MW4pepm9472iMzbryA4p-r-SG-OPnFftshB-0S8LB
|
|
|
76
77
|
basic_memory/cli/commands/command_utils.py,sha256=9_xUeeg1-nsnUw3MdNXOc_rhVROqhXqEDcTD3E_ga3A,5008
|
|
77
78
|
basic_memory/cli/commands/config.py,sha256=8IBqPrcgklhvHkVFG8v9laO0PqZkvfLsZ_jgbPkFCb4,8700
|
|
78
79
|
basic_memory/cli/commands/db.py,sha256=aQcjHpff15pfpDTKKpV-zz38TIJNfkOFQsCWggqbbU8,22596
|
|
79
|
-
basic_memory/cli/commands/doctor.py,sha256=
|
|
80
|
+
basic_memory/cli/commands/doctor.py,sha256=84AifKWTxcCibUOkZvJQ8P7PKuPK_P36KLy5d6aNvsQ,10755
|
|
80
81
|
basic_memory/cli/commands/format.py,sha256=pgXkk0ptU2quN0XzjURuapA5AqrIBu1y4j6J1t52ovI,6753
|
|
81
82
|
basic_memory/cli/commands/hook.py,sha256=tNtGzf4E40bhZQL6-SJwQpq8ehQmxm7EIae7O7-3jJg,60641
|
|
82
83
|
basic_memory/cli/commands/import_chatgpt.py,sha256=7SUGG_mmme0BuL4ov0lhb98VkTUldunCv-ehQirtwZE,3870
|
|
@@ -92,7 +93,7 @@ basic_memory/cli/commands/routing.py,sha256=jGI7Sx0_E_M1sLqMjY3lFSE_uNEzKDhj-DkI
|
|
|
92
93
|
basic_memory/cli/commands/schema.py,sha256=zGCrCM-PwWdZSVxD2_BF3dV_gdjtqp-qKFWVoitLAys,14260
|
|
93
94
|
basic_memory/cli/commands/status.py,sha256=u0nLbQPJAhGAtJzkQY6iVw6qDzGJL2Sv9V5osmliYzk,7345
|
|
94
95
|
basic_memory/cli/commands/tool.py,sha256=4q-WmCU6SftT3J_DkFH--dOLYmiCXd8k0_n-OhrRBNw,63870
|
|
95
|
-
basic_memory/cli/commands/update.py,sha256=
|
|
96
|
+
basic_memory/cli/commands/update.py,sha256=ncOWOkk4AXvpLdJP-SQslIIPaybDoF02DMDnqkfcteE,1417
|
|
96
97
|
basic_memory/cli/commands/workspace.py,sha256=ZpP-AicC4RJNCsDl5gSxJBDZVmcn0Q9TBtFsGOdeVNk,1043
|
|
97
98
|
basic_memory/cli/commands/cloud/__init__.py,sha256=tHu2fkrQ0EHN3uFz4lSxpt0Msa7aJgAXQnZ3m-nRC74,1005
|
|
98
99
|
basic_memory/cli/commands/cloud/api_client.py,sha256=CeL0n1hwISh6eNvMsfwc5GJ02TVlpysSYbqcDfIEU2k,5092
|
|
@@ -206,7 +207,7 @@ basic_memory/man/bm.1,sha256=iQhnHzJHSN3WRdMIphv22AnWTYR2SErzeKwCqQJ3LzQ,3406
|
|
|
206
207
|
basic_memory/markdown/__init__.py,sha256=DdzioCWtDnKaq05BHYLgL_78FawEHLpLXnp-kPSVfIc,501
|
|
207
208
|
basic_memory/markdown/entity_parser.py,sha256=7RUL7QXOQc00ez7OFOJB6kvsD8yY6K_yVD4VGSHOPxc,12629
|
|
208
209
|
basic_memory/markdown/markdown_processor.py,sha256=YK8KF8NY0N9nQM3I_8Pcxybf5SPOPnIL5kzmFBFBlx0,7429
|
|
209
|
-
basic_memory/markdown/plugins.py,sha256=
|
|
210
|
+
basic_memory/markdown/plugins.py,sha256=y_gfzywOC4r-WNTYPPnn-Px0eiCTwdm2MJnFXJEfIwg,12831
|
|
210
211
|
basic_memory/markdown/schemas.py,sha256=IHi4sm40ew4zNjb359imwiPJXGO6cIni_1f3YNVyZPs,2798
|
|
211
212
|
basic_memory/markdown/utils.py,sha256=EP8P8uDNrhcnvpgc_iaczIQK3r9Z6k1MHMKCcZvTzm0,4122
|
|
212
213
|
basic_memory/mcp/__init__.py,sha256=dsDOhKqjYeIbCULbHIxfcItTbqudEuEg1Np86eq0GEQ,35
|
|
@@ -255,7 +256,7 @@ basic_memory/mcp/tools/ui_sdk.py,sha256=bVkNJUk0hH6j3q47wVZYbGMe4Xxe0UwrGvbCMC0p
|
|
|
255
256
|
basic_memory/mcp/tools/utils.py,sha256=qMjO7UpCfGf5r3XsCCpelB-MPwSn8eGegLrh5SgEP08,31924
|
|
256
257
|
basic_memory/mcp/tools/view_note.py,sha256=PtW_qIKYa1cu0W7Y-DlQYHcIx6M1FqWxMSH9pSpZHKU,2846
|
|
257
258
|
basic_memory/mcp/tools/workspaces.py,sha256=m2G0vdYt2l084JtXq_t9a9V3_n6HnoT8MNSOvdclrXU,2826
|
|
258
|
-
basic_memory/mcp/tools/write_note.py,sha256=
|
|
259
|
+
basic_memory/mcp/tools/write_note.py,sha256=hbshPb59bKw1_JoTJtOgJl13wIbhXa80xwNn0Luyq2o,19529
|
|
259
260
|
basic_memory/mcp/ui/__init__.py,sha256=shSaivFGUaKaK7rUt3NNja8tGPbi1Bqie1uTC7FzVCU,193
|
|
260
261
|
basic_memory/mcp/ui/sdk.py,sha256=Vk23Cf4C_lA6BA2p21plE-Ia0r2-vpT6vw65D7swm-w,1764
|
|
261
262
|
basic_memory/mcp/ui/templates.py,sha256=eUrtb3p90dhZusu6b5EjasxXpcn0NsQDbRNiufQcegQ,822
|
|
@@ -270,7 +271,7 @@ basic_memory/models/base.py,sha256=2F45uI2-_xyEaHDeqifEHSTsiwIbDcwE50QjQcuggMI,2
|
|
|
270
271
|
basic_memory/models/knowledge.py,sha256=2D0aPlsgnjn5phWdAq-I4gMvn8chewIC1VzYLRgKQTM,16102
|
|
271
272
|
basic_memory/models/project.py,sha256=8IMKWA_Z7mecAzRByRGi7V1XQ1XaYL8r_K9k91pdS6Q,3418
|
|
272
273
|
basic_memory/models/relation_search_refresh.py,sha256=DA1v-N6G9nKJ7a-DvfSi13pGRdckNUfh_d9tiEvLqNY,1606
|
|
273
|
-
basic_memory/models/search.py,sha256=
|
|
274
|
+
basic_memory/models/search.py,sha256=beMfq6vtdSXLuYTsjZUEBP6x-e7QU2qN-fHTUFxEmzM,6833
|
|
274
275
|
basic_memory/picoschema/__init__.py,sha256=OsFNpVzzm38Y_0ELoTM_EcyGlnA1-_1DVx0ZKl45xNw,1270
|
|
275
276
|
basic_memory/picoschema/diff.py,sha256=xjCzbS4XzrW6lXKjsGPSitMWDd3fbFrsA2JW9CslBtk,4212
|
|
276
277
|
basic_memory/picoschema/inference.py,sha256=Cc02myST0zqmDKw0IUYjaPom8WrQFVvqrBbyurN4O5k,10575
|
|
@@ -306,7 +307,7 @@ basic_memory/repository/note_file_vacate_repository.py,sha256=3dyYc2ySkPJFEDMdH-
|
|
|
306
307
|
basic_memory/repository/observation_repository.py,sha256=mLvIhjM3pbORBNUF-YT9NiPleUHgPCHNAjdNoFXEJaE,5710
|
|
307
308
|
basic_memory/repository/openai_provider.py,sha256=kfGEqQYhi0dtOP91Ud9M9ARITz8ZKjpVJoPeRvQdLBc,4838
|
|
308
309
|
basic_memory/repository/pgvector_index.py,sha256=Vh16pPMAo4SeLpd_g4FIfdO4VdR5FLnYYBlMcg0B3Xg,16119
|
|
309
|
-
basic_memory/repository/postgres_search_repository.py,sha256=
|
|
310
|
+
basic_memory/repository/postgres_search_repository.py,sha256=6ySkknt5hOSGdNvblEGLqnEgxehQHliombcvhrvDEAg,62331
|
|
310
311
|
basic_memory/repository/prefixing_provider.py,sha256=8qGzW3_9Y40O794vk0QkR6MUxN07ZHVj0awf0WEObAI,3312
|
|
311
312
|
basic_memory/repository/project_info_repository.py,sha256=jr32DLlVWq8tmXOi7xALv1CxHlg9n6y5xlrz74G_CyM,317
|
|
312
313
|
basic_memory/repository/project_repository.py,sha256=ZzpAynhh9vgaJdZjTsWGjBPvPjyfzPOX1AWiGlsdh-c,12592
|
|
@@ -390,8 +391,8 @@ basic_memory/services/search_service.py,sha256=OcT2wMn7rN1GQ_0l8OH8KKcoTG4D7-Evf
|
|
|
390
391
|
basic_memory/services/service.py,sha256=V-d_8gOV07zGIQDpL-Ksqs3ZN9l3qf3HZOK1f_YNTag,336
|
|
391
392
|
basic_memory/templates/prompts/continue_conversation.hbs,sha256=trrDHSXA5S0JCbInMoUJL04xvCGRB_ku1RHNQHtl6ZI,3076
|
|
392
393
|
basic_memory/templates/prompts/search.hbs,sha256=H1cCIsHKp4VC1GrH2KeUB8pGe5vXFPqb2VPotypmeCA,3098
|
|
393
|
-
basic_memory-0.23.1.
|
|
394
|
-
basic_memory-0.23.1.
|
|
395
|
-
basic_memory-0.23.1.
|
|
396
|
-
basic_memory-0.23.1.
|
|
397
|
-
basic_memory-0.23.1.
|
|
394
|
+
basic_memory-0.23.1.dev20.dist-info/METADATA,sha256=kS21fqv6k0zvFoXQ3KaKUgv6UWpoDs_APp8C7FjTpqs,26848
|
|
395
|
+
basic_memory-0.23.1.dev20.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
396
|
+
basic_memory-0.23.1.dev20.dist-info/entry_points.txt,sha256=wvE2mRF6-Pg4weIYcfQ-86NOLZD4WJg7F7TIsRVFLb8,90
|
|
397
|
+
basic_memory-0.23.1.dev20.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
|
|
398
|
+
basic_memory-0.23.1.dev20.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|