Module: HTM::LongTermMemory::NodeOperations

Included in:
HTM::LongTermMemory
Defined in:
lib/htm/long_term_memory/node_operations.rb

Overview

Node CRUD operations for LongTermMemory

Handles creating, reading, updating, and deleting memory nodes with:

  • Content deduplication via SHA-256 hash

  • Soft delete restoration on duplicate content

  • Robot-node linking with remember tracking

  • Bulk access tracking

Instance Method Summary collapse

Instance Method Details

#add(content:, robot_id:, token_count: 0, embedding: nil, metadata: {}) ⇒ Hash

Add a node to long-term memory (with deduplication)

If content already exists (by content_hash), links the robot to the existing node and updates timestamps. Otherwise creates a new node.

Parameters:

  • content (String)

    Conversation message/utterance

  • token_count (Integer) (defaults to: 0)

    Token count

  • robot_id (Integer)

    Robot identifier

  • embedding (Array<Float>, nil) (defaults to: nil)

    Pre-generated embedding vector

  • metadata (Hash) (defaults to: {})

    Flexible metadata for the node (default: {})

Returns:

  • (Hash)

    { node_id:, is_new:, robot_node: }

Raises:

  • (ArgumentError)

    If metadata is not a Hash



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/htm/long_term_memory/node_operations.rb', line 27

def add(content:, robot_id:, token_count: 0, embedding: nil, metadata: {})
  # Validate metadata parameter
  unless .is_a?(Hash)
    raise ArgumentError, "metadata must be a Hash, got #{.class}"
  end
  content_hash = HTM::Models::Node.generate_content_hash(content)

  # Wrap in transaction to ensure data consistency
  HTM.db.transaction do
    # Check for existing node with same content (including soft-deleted)
    # This avoids unique constraint violations on content_hash
    existing_node = HTM::Models::Node.with_deleted.first(content_hash: content_hash)

    # If found but soft-deleted, restore it
    if existing_node&.deleted?
      existing_node.restore!
      HTM.logger.info "Restored soft-deleted node #{existing_node.id} for content match"
    end

    if existing_node
      # Link robot to existing node (or update if already linked)
      robot_node = link_robot_to_node(robot_id: robot_id, node: existing_node)

      # Update the node's updated_at timestamp
      existing_node.update(updated_at: Time.now)

      {
        node_id: existing_node.id,
        is_new: false,
        robot_node: robot_node
      }
    else
      # Prepare embedding if provided
      embedding_str = nil
      if embedding
        # Use centralized padding and sanitization
        padded_embedding = HTM::SqlBuilder.pad_embedding(embedding)
        embedding_str = HTM::SqlBuilder.sanitize_embedding(padded_embedding)
      end

      # Create new node
      node = HTM::Models::Node.create(
        content: content,
        content_hash: content_hash,
        token_count: token_count,
        embedding: embedding_str,
        metadata: 
      )

      # Link robot to new node
      robot_node = link_robot_to_node(robot_id: robot_id, node: node)

      # Selectively invalidate search-related cache entries only
      # (preserves unrelated cached data like tag queries)
      @cache&.invalidate_methods!(:search, :fulltext, :hybrid)

      {
        node_id: node.id,
        is_new: true,
        robot_node: robot_node
      }
    end
  end
end

#delete(node_id) ⇒ void

This method returns an undefined value.

Delete a node

Parameters:

  • node_id (Integer)

    Node database ID



158
159
160
161
162
163
164
# File 'lib/htm/long_term_memory/node_operations.rb', line 158

def delete(node_id)
  node = HTM::Models::Node.first(id: node_id)
  node&.delete

  # Selectively invalidate search-related cache entries only
  @cache&.invalidate_methods!(:search, :fulltext, :hybrid)
end

#exists?(node_id) ⇒ Boolean

Check if a node exists

Parameters:

  • node_id (Integer)

    Node database ID

Returns:

  • (Boolean)

    True if node exists



171
172
173
# File 'lib/htm/long_term_memory/node_operations.rb', line 171

def exists?(node_id)
  HTM::Models::Node.where(id: node_id).any?
end

Link a robot to a node (create or update robot_node record)

Parameters:

  • robot_id (Integer)

    Robot ID

  • node (HTM::Models::Node)

    Node to link

  • working_memory (Boolean) (defaults to: false)

    Whether node is in working memory (default: false)

Returns:



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/htm/long_term_memory/node_operations.rb', line 99

def link_robot_to_node(robot_id:, node:, working_memory: false)
  robot_node = HTM::Models::RobotNode.first(robot_id: robot_id, node_id: node.id)

  if robot_node
    # Existing link - record that robot remembered this again
    robot_node.record_remember!
    robot_node.update(working_memory: working_memory) if working_memory
  else
    # New link
    robot_node = HTM::Models::RobotNode.create(
      robot_id: robot_id,
      node_id: node.id,
      first_remembered_at: Time.now,
      last_remembered_at: Time.now,
      remember_count: 1,
      working_memory: working_memory
    )
  end

  robot_node
end

#mark_evicted(robot_id:, node_ids:) ⇒ void

This method returns an undefined value.

Mark nodes as evicted from working memory

Sets working_memory = false on the robot_nodes join table for the specified robot and node IDs.

Parameters:

  • robot_id (Integer)

    Robot ID whose working memory is being evicted

  • node_ids (Array<Integer>)

    Node IDs to mark as evicted



184
185
186
187
188
189
190
# File 'lib/htm/long_term_memory/node_operations.rb', line 184

def mark_evicted(robot_id:, node_ids:)
  return if node_ids.empty?

  HTM::Models::RobotNode
    .where(robot_id: robot_id, node_id: node_ids)
    .update(working_memory: false)
end

#retrieve(node_id) ⇒ Hash?

Retrieve a node by ID

Automatically tracks access by incrementing access_count and updating last_accessed. Uses a single UPDATE query instead of separate increment! and touch calls.

Parameters:

  • node_id (Integer)

    Node database ID

Returns:

  • (Hash, nil)

    Node data or nil



129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/htm/long_term_memory/node_operations.rb', line 129

def retrieve(node_id)
  node = HTM::Models::Node.first(id: node_id)
  return nil unless node

  # Track access in a single UPDATE query (instead of separate operations)
  node.this.update(
    access_count: Sequel[:access_count] + 1,
    last_accessed: Time.now
  )

  # Reload to get updated values
  node.refresh.to_hash
end

#track_access(node_ids) ⇒ void

This method returns an undefined value.

Track access for multiple nodes (bulk operation)

Updates access_count and last_accessed for all nodes in the array

Parameters:

  • node_ids (Array<Integer>)

    Node IDs that were accessed



199
200
201
202
203
204
205
206
207
# File 'lib/htm/long_term_memory/node_operations.rb', line 199

def track_access(node_ids)
  return if node_ids.empty?

  # Atomic batch update
  HTM::Models::Node.where(id: node_ids).update(
    access_count: Sequel[:access_count] + 1,
    last_accessed: Sequel.lit('NOW()')
  )
end

#update_last_accessed(node_id) ⇒ void

This method returns an undefined value.

Update last_accessed timestamp

Parameters:

  • node_id (Integer)

    Node database ID



148
149
150
151
# File 'lib/htm/long_term_memory/node_operations.rb', line 148

def update_last_accessed(node_id)
  node = HTM::Models::Node.first(id: node_id)
  node&.update(last_accessed: Time.now)
end