Packages
fnord
0.9.40
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
lib/store/project/conversation_index.ex
defmodule Store.Project.ConversationIndex do
@moduledoc """
Manages semantic index data for conversations within a project.
Index data is stored under the project's store path in a parallel
directory tree to conversations themselves:
<store_path>/conversations/index/<conversation-id>/
embeddings.json
metadata.json
This module is responsible for tracking which conversations are indexed,
determining which ones are new or stale, and reading/writing embeddings
and associated metadata.
"""
alias Store.Project
alias Store.Project.Conversation
@type metadata :: %{
optional(String.t()) => any
}
@type status :: %{
new: [Conversation.t()],
stale: [Conversation.t()],
deleted: [String.t()]
}
@index_dir "conversations/index"
@embeddings_filename "embeddings.json"
@metadata_filename "metadata.json"
@spec root(Project.t()) :: String.t()
def root(%Project{store_path: store_path}) do
Path.join(store_path, @index_dir)
end
@spec path_for(Project.t(), String.t()) :: String.t()
def path_for(project, conversation_id) do
project
|> root()
|> Path.join(conversation_id)
end
@doc """
Returns the status of the conversation index for the given project.
It classifies conversations into:
* `:deleted` - indexed conversations whose source conversation no longer exists
* `:stale` - conversations whose index metadata is stale compared to the
on-disk conversation timestamp or embedding model
* `:new` - conversations that exist but have no index entry
"""
@spec index_status(Project.t()) :: status
def index_status(%Project{} = project) do
source_conversations = Conversation.list(project.store_path)
indexed_conversations =
project
|> root()
|> Path.join("*/#{@metadata_filename}")
|> Path.wildcard()
|> Enum.map(&Path.dirname/1)
|> Enum.map(&Path.basename/1)
source_by_id = Map.new(source_conversations, &{&1.id, &1})
indexed_ids = MapSet.new(indexed_conversations)
source_ids = MapSet.new(Map.keys(source_by_id))
new_ids = MapSet.difference(source_ids, indexed_ids)
deleted_ids = MapSet.difference(indexed_ids, source_ids)
last_updated =
source_conversations
|> Enum.map(fn c ->
ts = Conversation.timestamp(c)
ts_int = if is_integer(ts), do: ts, else: DateTime.to_unix(ts)
{c.id, ts_int}
end)
|> Map.new()
last_indexed =
source_conversations
|> Enum.map(fn convo ->
case read_metadata(project, convo.id) do
{:ok, %{"last_indexed_ts" => ts}} -> {convo.id, ts}
_ -> {convo.id, -1}
end
end)
|> Map.new()
stale_convs =
source_conversations
|> Enum.filter(fn convo ->
ts_updated = Map.get(last_updated, convo.id, 0)
ts_indexed = Map.get(last_indexed, convo.id, 0)
ts_indexed < ts_updated
end)
|> Enum.reject(fn convo -> not MapSet.member?(indexed_ids, convo.id) end)
|> Enum.sort_by(&Map.get(last_indexed, &1.id, 0))
%{
new:
new_ids
|> Enum.map(&Map.fetch!(source_by_id, &1)),
stale: stale_convs,
deleted: MapSet.to_list(deleted_ids)
}
end
@doc """
Returns true when the given conversation still needs indexing in this
project. Stale when any of:
* No index entry on disk.
* The entry's `last_indexed_ts` is older than the conversation's
`timestamp`.
* The stored embedding was produced by a different model (vector
dimension doesn't match the current embedder).
Used by foreground indexers to re-check freshness inside a
per-conversation lock: if a parallel indexer already refreshed the
entry, skip instead of re-running the summarizer + embedder.
"""
@spec stale?(Project.t(), Conversation.t()) :: boolean
def stale?(%Project{} = project, %Conversation{} = convo) do
source_ts =
case Conversation.timestamp(convo) do
%DateTime{} = dt -> DateTime.to_unix(dt)
ts when is_integer(ts) -> ts
end
case read_embeddings(project, convo.id) do
{:ok, %{embeddings: embeddings, metadata: %{"last_indexed_ts" => indexed_ts}}}
when is_integer(indexed_ts) and is_list(embeddings) ->
indexed_ts < source_ts or
length(embeddings) != AI.Embeddings.dimensions()
_ ->
true
end
end
@doc """
Writes embeddings and metadata for a conversation.
The embeddings are stored in `embeddings.json` and the metadata in
`metadata.json` under the conversation's index directory.
"""
@spec write_embeddings(Project.t(), String.t(), any, metadata) :: :ok | {:error, term}
def write_embeddings(%Project{} = project, conversation_id, embeddings, metadata) do
dir = path_for(project, conversation_id)
with :ok <- File.mkdir_p(dir),
:ok <- write_json(Path.join(dir, @embeddings_filename), embeddings),
:ok <- write_json(Path.join(dir, @metadata_filename), metadata) do
:ok
end
end
@doc """
Reads embeddings and metadata for a conversation.
Returns `{:ok, %{embeddings: embeddings, metadata: metadata}}` on success
or an error tuple if either file cannot be read/decoded.
"""
@spec read_embeddings(Project.t(), String.t()) ::
{:ok, %{embeddings: any, metadata: metadata}} | {:error, term}
def read_embeddings(%Project{} = project, conversation_id) do
dir = path_for(project, conversation_id)
embeddings_path = Path.join(dir, @embeddings_filename)
metadata_path = Path.join(dir, @metadata_filename)
with {:ok, embeddings} <- read_json(embeddings_path),
{:ok, metadata} <- read_json(metadata_path) do
{:ok, %{embeddings: embeddings, metadata: metadata}}
end
end
@doc """
Reads only the metadata for a conversation index entry.
"""
@spec read_metadata(Project.t(), String.t()) :: {:ok, metadata} | {:error, term}
def read_metadata(%Project{} = project, conversation_id) do
dir = path_for(project, conversation_id)
metadata_path = Path.join(dir, @metadata_filename)
read_json(metadata_path)
end
@doc """
Enumerates all indexed conversations, yielding `{id, embedding_vector, metadata}`.
"""
@spec all_embeddings(Project.t()) :: Enumerable.t()
def all_embeddings(%Project{} = project) do
project
|> root()
|> Path.join("*/#{@embeddings_filename}")
|> Path.wildcard()
|> Stream.map(fn path ->
id =
path
|> Path.dirname()
|> Path.basename()
embeddings_result = read_json(path)
metadata_result = read_metadata(project, id)
case {embeddings_result, metadata_result} do
{{:ok, embeddings}, {:ok, metadata}} -> {id, embeddings, metadata}
_ -> nil
end
end)
|> Stream.reject(&is_nil/1)
end
@doc """
Deletes the index entry for the given conversation id.
"""
@spec delete(Project.t(), String.t()) :: :ok
def delete(%Project{} = project, conversation_id) do
project
|> path_for(conversation_id)
|> File.rm_rf!()
:ok
end
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
@spec write_json(String.t(), any) :: :ok | {:error, term}
defp write_json(path, data) do
case SafeJson.encode(data) do
{:ok, json} -> File.write(path, json)
error -> error
end
end
@spec read_json(String.t()) :: {:ok, any} | {:error, term}
defp read_json(path) do
case File.read(path) do
{:ok, contents} -> SafeJson.decode(contents)
error -> error
end
end
end