echo-mem 0.5.1__tar.gz → 0.5.2__tar.gz

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 (102) hide show
  1. {echo_mem-0.5.1/src/echo_mem.egg-info → echo_mem-0.5.2}/PKG-INFO +1 -1
  2. {echo_mem-0.5.1 → echo_mem-0.5.2}/pyproject.toml +1 -1
  3. {echo_mem-0.5.1 → echo_mem-0.5.2/src/echo_mem.egg-info}/PKG-INFO +1 -1
  4. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/retrieval/query_memory.py +68 -23
  5. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/server.py +46 -7
  6. {echo_mem-0.5.1 → echo_mem-0.5.2}/LICENSE +0 -0
  7. {echo_mem-0.5.1 → echo_mem-0.5.2}/LICENSE-APACHE-2.0 +0 -0
  8. {echo_mem-0.5.1 → echo_mem-0.5.2}/README.md +0 -0
  9. {echo_mem-0.5.1 → echo_mem-0.5.2}/setup.cfg +0 -0
  10. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_mem.egg-info/SOURCES.txt +0 -0
  11. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_mem.egg-info/dependency_links.txt +0 -0
  12. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_mem.egg-info/entry_points.txt +0 -0
  13. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_mem.egg-info/requires.txt +0 -0
  14. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_mem.egg-info/top_level.txt +0 -0
  15. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/__init__.py +0 -0
  16. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/audit/__init__.py +0 -0
  17. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/audit/get_audit_log.py +0 -0
  18. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/__init__.py +0 -0
  19. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/adopt.py +0 -0
  20. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/analyse.py +0 -0
  21. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/benchmark.py +0 -0
  22. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/calibrate.py +0 -0
  23. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/connect.py +0 -0
  24. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/dashboard.py +0 -0
  25. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/dashboard_html.py +0 -0
  26. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/export.py +0 -0
  27. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/graph.py +0 -0
  28. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/health.py +0 -0
  29. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/hooks.py +0 -0
  30. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/initdb.py +0 -0
  31. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/install.py +0 -0
  32. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/judge.py +0 -0
  33. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/main.py +0 -0
  34. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/merge.py +0 -0
  35. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/queue.py +0 -0
  36. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/quickstart.py +0 -0
  37. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/reattribute.py +0 -0
  38. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/reattribute_cmd.py +0 -0
  39. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/recall.py +0 -0
  40. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/reconcile.py +0 -0
  41. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/reindex.py +0 -0
  42. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/session_start.py +0 -0
  43. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/skill.py +0 -0
  44. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/status.py +0 -0
  45. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/stop_gate.py +0 -0
  46. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/trial.py +0 -0
  47. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/unmerge.py +0 -0
  48. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/cli/why.py +0 -0
  49. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/client.py +0 -0
  50. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/eval/__init__.py +0 -0
  51. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/eval/independent.py +0 -0
  52. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/eval/retrieval.py +0 -0
  53. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/eval/sweep.py +0 -0
  54. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/graph/__init__.py +0 -0
  55. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/graph/communities.py +0 -0
  56. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/infra/__init__.py +0 -0
  57. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/infra/config.py +0 -0
  58. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/infra/db.py +0 -0
  59. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/infra/logging.py +0 -0
  60. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/infra/pool.py +0 -0
  61. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/infra/project.py +0 -0
  62. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/ingestion/__init__.py +0 -0
  63. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/ingestion/activity.py +0 -0
  64. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/ingestion/bootstrap.py +0 -0
  65. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/ingestion/capture.py +0 -0
  66. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/ingestion/embeddings.py +0 -0
  67. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/ingestion/neighbourhood.py +0 -0
  68. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/ingestion/resolution.py +0 -0
  69. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/ingestion/write_episode.py +0 -0
  70. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/env.py +0 -0
  71. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0001_initial_schema.py +0 -0
  72. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0002_trial_observations.py +0 -0
  73. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0003_project_and_agent_provenance.py +0 -0
  74. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0004_bootstrap_state.py +0 -0
  75. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0005_recall_save_guards.py +0 -0
  76. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0006_project_analysis.py +0 -0
  77. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0007_backfill_unknown_agent.py +0 -0
  78. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0008_read_events.py +0 -0
  79. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0009_read_event_attribution.py +0 -0
  80. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0010_stop_gate_fired.py +0 -0
  81. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0011_backfill_absent_agent.py +0 -0
  82. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0012_read_event_agent.py +0 -0
  83. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0013_active_fact_indexes.py +0 -0
  84. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0014_session_activity.py +0 -0
  85. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0015_retracted_observations.py +0 -0
  86. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0016_one_node_per_name.py +0 -0
  87. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0017_writer_version.py +0 -0
  88. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0018_trial_runs_are_history.py +0 -0
  89. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0019_versions_expected_since.py +0 -0
  90. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0020_who_closed_a_queued_document.py +0 -0
  91. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0021_delivery_evidence.py +0 -0
  92. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0022_independent_evaluation.py +0 -0
  93. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0023_second_judge.py +0 -0
  94. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0024_node_by_id.py +0 -0
  95. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/retrieval/__init__.py +0 -0
  96. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/retrieval/causality.py +0 -0
  97. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/retrieval/fusion.py +0 -0
  98. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/skill/SKILL.md +0 -0
  99. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/trial/__init__.py +0 -0
  100. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/trial/check.py +0 -0
  101. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/trial/observations.py +0 -0
  102. {echo_mem-0.5.1 → echo_mem-0.5.2}/src/echo_memory/trial/reads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: echo-mem
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: A long-horizon memory architecture for AI agents
5
5
  License-Expression: BUSL-1.1
6
6
  Requires-Python: >=3.11
@@ -6,7 +6,7 @@
6
6
  # (`-m echo_memory.server`) and every existing install reference it, and
7
7
  # renaming the module would break live configuration to fix a cosmetic mismatch.
8
8
  name = "echo-mem"
9
- version = "0.5.1"
9
+ version = "0.5.2"
10
10
  description = "A long-horizon memory architecture for AI agents"
11
11
  readme = "README.md"
12
12
  # BUSL-1.1 is the SPDX identifier for the Business Source License 1.1, so
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: echo-mem
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: A long-horizon memory architecture for AI agents
5
5
  License-Expression: BUSL-1.1
6
6
  Requires-Python: >=3.11
@@ -562,33 +562,68 @@ def _provenance(raw, agent_id, project) -> dict | None:
562
562
  return out
563
563
 
564
564
 
565
+ def _missing_similarity(
566
+ conn, group_id: str, embedding: list[float], edge_ids: list[str]
567
+ ) -> dict[str, float]:
568
+ """Cosine for facts the vector channel never scored.
569
+
570
+ The lexical channel computes no similarity, so a fact only it found
571
+ arrived with score null. That is an implementation detail - which channel
572
+ happened to retrieve it - leaking into a field callers read as relevance.
573
+
574
+ Fixed by computing the number instead of omitting it. At most top_k rows,
575
+ by primary key, with the query embedding already in hand.
576
+ """
577
+ if not edge_ids:
578
+ return {}
579
+ rows = conn.execute(
580
+ """SELECT fe.edge_id::text, -(fe.embedding <#> %s::vector)
581
+ FROM public.fact_embedding fe
582
+ WHERE fe.group_id = %s
583
+ AND fe.edge_id = ANY(SELECT unnest(%s::text[])::graphid)""",
584
+ (embedding, group_id, list(edge_ids)),
585
+ ).fetchall()
586
+ return {str(edge_id): float(score) for edge_id, score in rows}
587
+
588
+
565
589
  def _annotate(
566
590
  facts: list[dict], similarity: dict[str, float],
567
591
  vector_ids: set[str], lexical_ids: set[str],
568
592
  ) -> None:
569
593
  """Say why each fact is in the answer, in place.
570
594
 
571
- Retrieval returned a bare ordered list and nothing else, so a caller who
572
- wanted "only use this if it is actually about my question" had no number
573
- to test. The first customer to hit it wrote relevance filters at four
574
- call sites to reconstruct, from the fact text, something this function
575
- already knew.
576
-
577
- Three fields, each chosen because a caller can act on it:
578
-
579
- rank position in this answer, 1 first. Cheap, and enough for
580
- "only consider the top three".
581
- score cosine similarity to the query, or null when the fact came
582
- only from the lexical channel and no similarity was computed.
583
- This is the number to threshold on: unlike a fusion score it
584
- means the same thing from one query to the next, and it is
585
- the same quantity the adaptive floor already trusts.
586
- matched which channels found it. A fact both channels found is a
587
- different kind of answer from one only the vector channel
588
- reached, and that distinction is free here and impossible to
589
- recover afterwards.
590
-
591
- Absent on a digest, which ranks nothing.
595
+ rank position in this answer, 1 first. THIS is the ordering to
596
+ trust and the one to truncate by. It is the fused result of
597
+ every channel, which is the system's whole opinion; score is
598
+ one input to it, and re-sorting by a single input discards
599
+ the fusion.
600
+ score cosine similarity to the query. Present on every fact of
601
+ every answer the MCP surface can ask for, and it means the
602
+ same thing for every fact whatever channel found it.
603
+ Diagnostic, NOT a correctness gate: a fact found by exact
604
+ keyword match can be the right answer at a low semantic
605
+ score.
606
+
607
+ Absent in exactly one place, and not one a tool caller can
608
+ reach: the UserPromptSubmit hook runs lexical_only in a fresh
609
+ process precisely to avoid a 6.2 second model load, so there
610
+ is no embedding to compare against and no honest number to
611
+ give. Omitted rather than faked.
612
+ matched which channels found it, as information rather than quality.
613
+
614
+ The null that used to appear here caused real harm and the shape of it is
615
+ worth keeping. score was omitted for lexical-only facts, a customer sorted
616
+ by score with nulls last, and every fact full text search had found went
617
+ to the back of the list - where a character budget truncated it and the
618
+ model filled the gap by inventing something. The fact it dropped was
619
+ correct and was in the store.
620
+
621
+ Their own calibration says why that is backwards. Over ten questions with
622
+ every fact judged against every question: lexical-only scored R@3 0.640
623
+ against vector-only's 0.460, and beat the fused ranking outright on two of
624
+ the ten. The channel that was being demoted has the better recall.
625
+
626
+ Absent on a digest too, which ranks nothing against nothing.
592
627
  """
593
628
  for position, fact in enumerate(facts, start=1):
594
629
  edge_id = fact["fact_id"]
@@ -599,7 +634,8 @@ def _annotate(
599
634
  if not matched:
600
635
  continue
601
636
  fact["rank"] = position
602
- fact["score"] = round(similarity[edge_id], 4) if edge_id in similarity else None
637
+ if edge_id in similarity:
638
+ fact["score"] = round(similarity[edge_id], 4)
603
639
  fact["matched"] = matched
604
640
 
605
641
 
@@ -653,6 +689,7 @@ def query_memory(
653
689
  return {"error": str(e)}
654
690
 
655
691
  similarity: dict[str, float] = {}
692
+ query_embedding: list[float] | None = None
656
693
  if digest:
657
694
  ranked_ids = _digest_candidates(conn, group_id, top_k)
658
695
  vector_ids, lexical_ids = [], []
@@ -661,7 +698,7 @@ def query_memory(
661
698
  lexical_ids = _lexical_any_candidates(conn, group_id, query, LIST_DEPTH)
662
699
  ranked_ids = lexical_ids[:top_k]
663
700
  else:
664
- embedding = embedder.embed(query)
701
+ embedding = query_embedding = embedder.embed(query)
665
702
  vector_ids = _vector_candidates(
666
703
  conn, group_id, embedding, LIST_DEPTH, floor=floor, scores=similarity
667
704
  )
@@ -732,6 +769,14 @@ def query_memory(
732
769
 
733
770
  facts_by_id = _fetch_facts(conn, ranked_ids)
734
771
  facts = [facts_by_id[edge_id] for edge_id in ranked_ids if edge_id in facts_by_id]
772
+ # Score whatever the vector channel did not, so every returned fact has
773
+ # one. Only on the query path: a digest ranks nothing and has no query to
774
+ # be similar to.
775
+ if query_embedding is not None:
776
+ unscored = [f["fact_id"] for f in facts if f["fact_id"] not in similarity]
777
+ similarity.update(
778
+ _missing_similarity(conn, group_id, query_embedding, unscored)
779
+ )
735
780
  _annotate(facts, similarity, set(vector_ids), set(lexical_ids))
736
781
 
737
782
  log_query_memory(
@@ -5,6 +5,7 @@ Runs over stdio by default (mcp.server.mcpserver's MCPServer.run default),
5
5
  not a network listener at all, let alone one bound beyond localhost; see
6
6
  the design doc's Constraints ("v1 is single-user, local-only")."""
7
7
 
8
+ import atexit
8
9
  import inspect
9
10
  import json
10
11
  import threading
@@ -106,6 +107,30 @@ def startup(config: Config | None = None, embedder: Embedder | None = None) -> N
106
107
  # a background task turns into confusing output somewhere else.
107
108
  _state.warming = threading.Thread(target=_warm, args=(warm,), daemon=True)
108
109
  _state.warming.start()
110
+ # Wait for it at exit, briefly, rather than letting the interpreter
111
+ # tear down underneath it.
112
+ #
113
+ # A daemon thread is killed abruptly at shutdown. This one is inside
114
+ # torch when that happens, and killing a thread mid C++ produces
115
+ # "terminate called without an active exception" and SIGABRT - exit
116
+ # code 134, after every test has already passed. CI saw exactly that:
117
+ # 771 passed, 5 skipped, then aborted. It is timing dependent, so it
118
+ # is rare on a machine with the model cached and common on one
119
+ # downloading it, which is the wrong way round for a release gate.
120
+ #
121
+ # Bounded, because the point of loading in the background is that
122
+ # nothing waits for it. A load still running after this long is one
123
+ # the process was never going to benefit from anyway.
124
+ atexit.register(_finish_warming)
125
+
126
+
127
+ WARM_SHUTDOWN_GRACE_SECONDS = 10.0
128
+
129
+
130
+ def _finish_warming() -> None:
131
+ warming = getattr(_state, "warming", None)
132
+ if warming is not None and warming.is_alive():
133
+ warming.join(timeout=WARM_SHUTDOWN_GRACE_SECONDS)
109
134
 
110
135
 
111
136
  def _warm(warm) -> None:
@@ -119,8 +144,16 @@ def _warm(warm) -> None:
119
144
  "embedder_warm",
120
145
  extra={"duration_ms": (time.perf_counter() - started) * 1000},
121
146
  )
122
- except Exception: # see docstring
123
- _logger.warning("embedder_warm_failed", exc_info=True)
147
+ except Exception: # noqa: BLE001 - see docstring
148
+ # Logging can itself fail here, and does: a thread still running while
149
+ # the interpreter shuts down finds the log stream closed and atexit
150
+ # refusing new registrations, so reporting the first failure raises a
151
+ # second one out of a thread with nowhere to put it. The report is
152
+ # best effort; the swallow is the contract in the docstring.
153
+ try:
154
+ _logger.warning("embedder_warm_failed", exc_info=True)
155
+ except Exception: # noqa: BLE001, S110 - nowhere left to report to
156
+ pass
124
157
 
125
158
 
126
159
  def _tool(fn):
@@ -254,11 +287,17 @@ def query_memory(scope: str, query: str | None = None, top_k: int = 10, digest:
254
287
  facts instead, as an opt-in "catch me up" convenience; call it
255
288
  explicitly at session start if you want one, it's never automatic.
256
289
 
257
- Each fact carries rank (1 first), score and matched. score is cosine
258
- similarity to your query, comparable across queries, and null when only
259
- the lexical channel found the fact. matched names the channels that did:
260
- a fact both found is a stronger answer than one only one reached. Use
261
- them instead of writing your own relevance filter over the text.
290
+ Each fact carries rank (1 first), score and matched.
291
+
292
+ If you must drop facts to fit a budget, drop by rank. It is the fused
293
+ result of every channel, which is this server's whole opinion; score is
294
+ one input to it, and re-sorting by a single input throws the rest away.
295
+
296
+ score is cosine similarity to your query, present on every fact and
297
+ comparable across queries. It is diagnostic, not a correctness test: a
298
+ fact found by exact keyword match can be the right answer at a low
299
+ score. matched names the channels that found it, as information - full
300
+ text search is not the weaker one, it has the better recall here.
262
301
 
263
302
  A pending_ingest field appears when memory files have been written that
264
303
  the graph hasn't heard about yet. Read each listed file and call
File without changes
File without changes
File without changes
File without changes