memoflow 0.1.0

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.
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Memoflow
4
+ class ProviderContext
5
+ def initialize(remote_url:, repo_path:, env: ENV, cli_runner: nil)
6
+ @remote_url = remote_url.to_s.strip
7
+ @repo_path = repo_path.to_s
8
+ @env = env
9
+ @cli_runner = cli_runner || method(:default_cli_runner)
10
+ end
11
+
12
+ def capture
13
+ {
14
+ provider: provider,
15
+ repository: repository,
16
+ pull_request: pull_request
17
+ }.compact
18
+ end
19
+
20
+ private
21
+
22
+ def provider
23
+ return "github" if source_text.include?("github")
24
+ return "gitlab" if source_text.include?("gitlab")
25
+ return "bitbucket" if source_text.include?("bitbucket")
26
+
27
+ nil
28
+ end
29
+
30
+ def repository
31
+ @env["GITHUB_REPOSITORY"] || @env["CI_PROJECT_PATH"] || parsed_repository
32
+ end
33
+
34
+ def pull_request
35
+ payload = github_pull_request || gitlab_merge_request || github_pull_request_from_cli || gitlab_merge_request_from_cli
36
+ payload if payload && payload[:number]
37
+ end
38
+
39
+ def github_pull_request
40
+ number = @env["GITHUB_PR_NUMBER"] || @env["PR_NUMBER"]
41
+ return unless number
42
+
43
+ {
44
+ number: number.to_s,
45
+ title: @env["GITHUB_PR_TITLE"],
46
+ body: @env["GITHUB_PR_BODY"],
47
+ source: "github_env"
48
+ }
49
+ end
50
+
51
+ def gitlab_merge_request
52
+ number = @env["CI_MERGE_REQUEST_IID"]
53
+ return unless number
54
+
55
+ {
56
+ number: number.to_s,
57
+ title: @env["CI_MERGE_REQUEST_TITLE"],
58
+ body: @env["CI_MERGE_REQUEST_DESCRIPTION"],
59
+ source: "gitlab_env"
60
+ }
61
+ end
62
+
63
+ def parsed_repository
64
+ cleaned = @remote_url.sub(/\Agit@([^:]+):/, '\1/').sub(%r{\Ahttps?://}, "")
65
+ parts = cleaned.split("/")
66
+ return if parts.length < 3
67
+
68
+ "#{parts[-2]}/#{parts[-1].sub(/\.git\z/, "")}"
69
+ end
70
+
71
+ def source_text
72
+ [@remote_url, @env["GITHUB_SERVER_URL"], @env["CI_SERVER_URL"]].compact.join(" ").downcase
73
+ end
74
+
75
+ def github_pull_request_from_cli
76
+ return unless provider == "github"
77
+
78
+ output = run_cli("gh", "pr", "view", "--json", "number,title,body", "--repo", repository.to_s)
79
+ return unless output
80
+
81
+ payload = JSON.parse(output)
82
+ {
83
+ number: payload["number"].to_s,
84
+ title: payload["title"],
85
+ body: payload["body"],
86
+ source: "gh_cli"
87
+ }
88
+ rescue JSON::ParserError
89
+ nil
90
+ end
91
+
92
+ def gitlab_merge_request_from_cli
93
+ return unless provider == "gitlab"
94
+
95
+ output = run_cli("glab", "mr", "view", "--output", "json")
96
+ return unless output
97
+
98
+ payload = JSON.parse(output)
99
+ {
100
+ number: (payload["iid"] || payload["id"]).to_s,
101
+ title: payload["title"],
102
+ body: payload["description"],
103
+ source: "glab_cli"
104
+ }
105
+ rescue JSON::ParserError
106
+ nil
107
+ end
108
+
109
+ def run_cli(*command)
110
+ result = @cli_runner.call(command, @repo_path)
111
+ return unless result[:success]
112
+
113
+ result[:output]
114
+ end
115
+
116
+ def default_cli_runner(command, repo_path)
117
+ output, status = Open3.capture2e(*command, chdir: repo_path)
118
+ { success: status.success?, output: output }
119
+ rescue Errno::ENOENT
120
+ { success: false, output: "" }
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Memoflow
4
+ class Server
5
+ def initialize(client:, host:, port:)
6
+ @client = client
7
+ @host = host
8
+ @port = port
9
+ @running = true
10
+ end
11
+
12
+ def start
13
+ trap_signals
14
+ @server = TCPServer.new(@host, @port)
15
+
16
+ while @running
17
+ socket = nil
18
+ begin
19
+ socket = @server.accept
20
+ handle(socket)
21
+ rescue IOError, SystemCallError
22
+ break unless @running
23
+ ensure
24
+ socket&.close
25
+ end
26
+ end
27
+ ensure
28
+ @server&.close
29
+ end
30
+
31
+ private
32
+
33
+ def handle(socket)
34
+ request_line = socket.gets
35
+ return unless request_line
36
+
37
+ _method, target, _version = request_line.split(" ", 3)
38
+ consume_headers(socket)
39
+ path, query = parse_target(target)
40
+ body = route(path, query)
41
+ socket.write(response(body))
42
+ end
43
+
44
+ def consume_headers(socket)
45
+ while (line = socket.gets)
46
+ break if line == "\r\n"
47
+ end
48
+ end
49
+
50
+ def parse_target(target)
51
+ uri = URI.parse(target)
52
+ query = URI.decode_www_form(uri.query.to_s).to_h
53
+ [uri.path, query]
54
+ end
55
+
56
+ def route(path, query)
57
+ payload = case path
58
+ when "/health"
59
+ { ok: true, current_task_id: @client.current_task_id }
60
+ when "/query"
61
+ @client.query(query["q"], limit: integer_param(query["limit"], 5))
62
+ when "/context"
63
+ { packet: @client.context_packet(query: query["q"], limit: integer_param(query["limit"], 5)) }
64
+ when "/tasks"
65
+ @client.tasks(limit: 20)
66
+ else
67
+ { error: "not_found" }
68
+ end
69
+
70
+ JSON.generate(payload)
71
+ end
72
+
73
+ def response(body)
74
+ [
75
+ "HTTP/1.1 200 OK",
76
+ "Content-Type: application/json",
77
+ "Content-Length: #{body.bytesize}",
78
+ "Connection: close",
79
+ "",
80
+ body
81
+ ].join("\r\n")
82
+ end
83
+
84
+ def integer_param(value, default)
85
+ Integer(value)
86
+ rescue ArgumentError, TypeError
87
+ default
88
+ end
89
+
90
+ def trap_signals
91
+ %w[INT TERM].each do |signal|
92
+ trap(signal) do
93
+ @running = false
94
+ @server&.close
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Memoflow
4
+ class Store
5
+ STATE_DIRS = %w[commits annotations tasks sessions state].freeze
6
+
7
+ def initialize(root:, encryptor:)
8
+ @root = Pathname.new(root)
9
+ @encryptor = encryptor
10
+ end
11
+
12
+ def setup!
13
+ STATE_DIRS.each do |dir|
14
+ FileUtils.mkdir_p(@root.join(dir))
15
+ end
16
+ end
17
+
18
+ def write_commit(record)
19
+ write("commits", "#{record.fetch(:sha)}.json.enc", record)
20
+ end
21
+
22
+ def write_annotation(record)
23
+ write("annotations", "#{record.fetch(:id)}.json.enc", record)
24
+ end
25
+
26
+ def write_task(record)
27
+ write("tasks", "#{record.fetch(:id)}.json.enc", record)
28
+ end
29
+
30
+ def write_session(record)
31
+ write("sessions", "#{record.fetch(:id)}.json.enc", record)
32
+ end
33
+
34
+ def write_state(name, record)
35
+ write("state", "#{name}.json.enc", record)
36
+ end
37
+
38
+ def export_bundle(path)
39
+ with_lock do
40
+ payload = {
41
+ exported_at: Time.now.utc.iso8601,
42
+ version: Memoflow::VERSION,
43
+ schema_version: Memoflow::SCHEMA_VERSION,
44
+ records: read_all,
45
+ state: read_scope("state")
46
+ }
47
+ atomic_write(Pathname.new(path), @encryptor.encrypt(JSON.generate(payload)))
48
+ end
49
+ end
50
+
51
+ def import_bundle(path)
52
+ with_lock do
53
+ payload = JSON.parse(@encryptor.decrypt(File.binread(path)), symbolize_names: true)
54
+ Array(payload[:records]).each { |record| import_record(record) }
55
+ Array(payload[:state]).each do |record|
56
+ name = File.basename(record.fetch(:_scope_name, "current_task"))
57
+ write_state(name, record.reject { |key, _| key == :_scope_name })
58
+ end
59
+ end
60
+ end
61
+
62
+ def prune!(keep_days: nil, max_records: nil)
63
+ with_lock do
64
+ removed = []
65
+ %w[commits annotations tasks sessions].each do |scope|
66
+ records = Dir[@root.join(scope, "*.enc")].sort
67
+ if keep_days
68
+ cutoff = Time.now.utc - (keep_days * 86_400)
69
+ records.each do |path|
70
+ entry = read_file(path)
71
+ timestamp = extract_timestamp(entry)
72
+ next unless timestamp && timestamp < cutoff
73
+
74
+ File.delete(path)
75
+ removed << path
76
+ end
77
+ end
78
+
79
+ next unless max_records
80
+
81
+ current = Dir[@root.join(scope, "*.enc")].sort_by { |path| extract_timestamp(read_file(path)) || Time.at(0) }
82
+ overflow = current.length - max_records
83
+ current.first(overflow).each do |path|
84
+ next if removed.include?(path)
85
+
86
+ File.delete(path)
87
+ removed << path
88
+ end
89
+ end
90
+
91
+ removed
92
+ end
93
+ end
94
+
95
+ def commit?(sha)
96
+ @root.join("commits", "#{sha}.json.enc").exist?
97
+ end
98
+
99
+ def read_state(name)
100
+ path = @root.join("state", "#{name}.json.enc")
101
+ return unless path.exist?
102
+
103
+ read_file(path)
104
+ end
105
+
106
+ def read_scope(scope)
107
+ Dir[@root.join(scope, "*.enc")].sort.map do |path|
108
+ record = read_file(path)
109
+ record.merge(_scope_name: File.basename(path, ".json.enc"))
110
+ end
111
+ end
112
+
113
+ def read_all
114
+ STATE_DIRS.reject { |scope| scope == "state" }.flat_map { |scope| read_scope(scope) }
115
+ end
116
+
117
+ private
118
+
119
+ def write(scope, filename, record)
120
+ with_lock do
121
+ setup!
122
+ path = @root.join(scope, filename)
123
+ payload = JSON.generate(envelope(record).transform_keys(&:to_s))
124
+ atomic_write(path, @encryptor.encrypt(payload))
125
+ end
126
+ end
127
+
128
+ def read_file(path)
129
+ payload = File.binread(path)
130
+ migrate_record(JSON.parse(@encryptor.decrypt(payload), symbolize_names: true))
131
+ end
132
+
133
+ def import_record(record)
134
+ scope =
135
+ case record[:type]
136
+ when "commit" then "commits"
137
+ when "annotation" then "annotations"
138
+ when "task" then "tasks"
139
+ when "session" then "sessions"
140
+ else
141
+ nil
142
+ end
143
+ return unless scope
144
+
145
+ key = record[:sha] || record[:id]
146
+ write(scope, "#{key}.json.enc", record.reject { |field, _| field == :_scope_name })
147
+ end
148
+
149
+ def extract_timestamp(entry)
150
+ value = entry[:timestamp] || entry[:committed_at] || entry[:updated_at] || entry[:started_at]
151
+ Time.parse(value) if value
152
+ rescue ArgumentError
153
+ nil
154
+ end
155
+
156
+ def envelope(record)
157
+ record.merge(
158
+ _schema_version: Memoflow::SCHEMA_VERSION,
159
+ _stored_at: Time.now.utc.iso8601
160
+ )
161
+ end
162
+
163
+ def migrate_record(record)
164
+ return record if record[:_schema_version] == Memoflow::SCHEMA_VERSION
165
+
166
+ record.merge(_schema_version: Memoflow::SCHEMA_VERSION)
167
+ end
168
+
169
+ def with_lock
170
+ setup!
171
+ File.open(@root.join(".lock"), File::RDWR | File::CREAT, 0o644) do |file|
172
+ file.flock(File::LOCK_EX)
173
+ yield
174
+ ensure
175
+ file.flock(File::LOCK_UN)
176
+ end
177
+ end
178
+
179
+ def atomic_write(path, payload)
180
+ temp_path = path.sub_ext("#{path.extname}.tmp-#{SecureRandom.hex(6)}")
181
+ File.binwrite(temp_path, payload)
182
+ File.rename(temp_path, path)
183
+ path
184
+ ensure
185
+ File.delete(temp_path) if temp_path && File.exist?(temp_path)
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Memoflow
4
+ class Vectorizer
5
+ DIMENSIONS = 128
6
+
7
+ class << self
8
+ def encode(text)
9
+ vector = Array.new(DIMENSIONS, 0.0)
10
+ tokens(text).each do |token|
11
+ bucket = Zlib.crc32(token) % DIMENSIONS
12
+ vector[bucket] += 1.0
13
+ end
14
+
15
+ normalize(vector)
16
+ end
17
+
18
+ def similarity(left, right)
19
+ return 0.0 if left.nil? || right.nil?
20
+ return 0.0 if left.empty? || right.empty?
21
+
22
+ left.zip(right).sum { |a, b| a.to_f * b.to_f }
23
+ end
24
+
25
+ def indexable_text(entry)
26
+ pr = entry[:pull_request] || {}
27
+ [
28
+ entry[:summary],
29
+ entry[:subject],
30
+ entry[:body],
31
+ entry[:title],
32
+ entry[:description],
33
+ Array(entry[:changed_files]).join(" "),
34
+ Array(entry[:tags]).join(" "),
35
+ entry[:task_id],
36
+ entry[:repository],
37
+ entry[:provider],
38
+ pr[:title],
39
+ pr[:body]
40
+ ].compact.join(" ")
41
+ end
42
+
43
+ private
44
+
45
+ def tokens(text)
46
+ text.to_s.downcase.scan(/[a-z0-9_\/.-]{2,}/)
47
+ end
48
+
49
+ def normalize(vector)
50
+ magnitude = Math.sqrt(vector.sum { |value| value * value })
51
+ return vector if magnitude.zero?
52
+
53
+ vector.map { |value| (value / magnitude).round(6) }
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Memoflow
4
+ VERSION = "0.1.0"
5
+ SCHEMA_VERSION = 1
6
+ end
data/lib/memoflow.rb ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "openssl"
6
+ require "open3"
7
+ require "pathname"
8
+ require "securerandom"
9
+ require "shellwords"
10
+ require "socket"
11
+ require "time"
12
+ require "uri"
13
+ require "zlib"
14
+
15
+ require_relative "memoflow/client"
16
+ require_relative "memoflow/cli"
17
+ require_relative "memoflow/configuration"
18
+ require_relative "memoflow/encryptor"
19
+ require_relative "memoflow/embedding_provider"
20
+ require_relative "memoflow/errors"
21
+ require_relative "memoflow/git_context"
22
+ require_relative "memoflow/hook_installer"
23
+ require_relative "memoflow/provider_context"
24
+ require_relative "memoflow/server"
25
+ require_relative "memoflow/store"
26
+ require_relative "memoflow/vectorizer"
27
+ require_relative "memoflow/version"
28
+
29
+ module Memoflow
30
+ class << self
31
+ def configuration
32
+ @configuration ||= Configuration.new
33
+ end
34
+
35
+ def configure
36
+ yield(configuration)
37
+ end
38
+
39
+ def client(repo_path: Dir.pwd, env: ENV, embedding_provider: nil)
40
+ Client.new(configuration: configuration, repo_path: repo_path, env: env, embedding_provider: embedding_provider)
41
+ end
42
+
43
+ def reset_configuration!
44
+ @configuration = Configuration.new
45
+ end
46
+ end
47
+ end
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: memoflow
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Iksha Labs
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.25'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.25'
27
+ description: Memoflow captures git-backed development context and exposes a lightweight
28
+ query API for AI assistants.
29
+ email:
30
+ - opensource@ikshalabs.com
31
+ executables:
32
+ - memoflow
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - LICENSE.txt
37
+ - README.md
38
+ - bin/memoflow
39
+ - examples/embedder.rb
40
+ - lib/memoflow.rb
41
+ - lib/memoflow/cli.rb
42
+ - lib/memoflow/client.rb
43
+ - lib/memoflow/configuration.rb
44
+ - lib/memoflow/embedding_provider.rb
45
+ - lib/memoflow/encryptor.rb
46
+ - lib/memoflow/errors.rb
47
+ - lib/memoflow/git_context.rb
48
+ - lib/memoflow/hook_installer.rb
49
+ - lib/memoflow/provider_context.rb
50
+ - lib/memoflow/server.rb
51
+ - lib/memoflow/store.rb
52
+ - lib/memoflow/vectorizer.rb
53
+ - lib/memoflow/version.rb
54
+ homepage: https://github.com/ikshalabs/memoflow
55
+ licenses:
56
+ - MIT
57
+ metadata:
58
+ source_code_uri: https://github.com/ikshalabs/memoflow
59
+ changelog_uri: https://github.com/ikshalabs/memoflow/releases
60
+ post_install_message:
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '3.1'
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 3.5.22
76
+ signing_key:
77
+ specification_version: 4
78
+ summary: Encrypted, compressed repo context for AI coding assistants
79
+ test_files: []