robot_lab-am 0.0.1

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
+ require "yaml"
4
+ require "ruby_llm"
5
+
6
+ module RobotLab
7
+ module Am
8
+ # Summarizes a bounded window of Events into a structured Intent with a
9
+ # one-shot RubyLLM chat — no robots, no robot_lab dependency, so the gem
10
+ # is useful outside the robot_lab-to environment too. `chat:` is
11
+ # injectable so callers (and tests) never have to make a real LLM call
12
+ # to exercise this class.
13
+ #
14
+ # Provider, model, API base, and key come from Am::Config (defaults.yml
15
+ # -> user config file -> RLAM_* env vars -> keyword overrides).
16
+ # The shipped default is a local model served by LM Studio's
17
+ # OpenAI-compatible API (`lms server start`), not a hosted provider —
18
+ # inference over your own activity log shouldn't require sending it,
19
+ # or an API key, anywhere.
20
+ class Inferrer
21
+ # LM Studio ignores the key but RubyLLM's :openai provider requires
22
+ # one to be configured; used only when neither the config nor the
23
+ # provider's conventional env var supplies a real key.
24
+ PLACEHOLDER_API_KEY = "no-key-required"
25
+
26
+ SYSTEM_PROMPT = <<~PROMPT
27
+ You infer what a software developer is currently working on from a
28
+ timestamped activity log: their own git commits and uncommitted
29
+ changes, their own messages in a Claude Code session, and commands
30
+ they ran in a terminal. Respond with ONLY YAML, no other text, in
31
+ exactly this shape:
32
+
33
+ goal: <one or two sentences on what they're currently working toward>
34
+ confidence: <low|medium|high>
35
+ evidence:
36
+ - <short reference to a specific event that supports the goal>
37
+ open_questions:
38
+ - <anything ambiguous or unresolved>
39
+ PROMPT
40
+
41
+ def initialize(chat: nil, config: nil, model: nil, provider: nil)
42
+ @chat = chat
43
+ @config = config
44
+ @model = model
45
+ @provider = provider
46
+ end
47
+
48
+ def infer(events, repo:)
49
+ return empty_intent if events.empty?
50
+
51
+ parse(chat.ask(build_prompt(events, repo)).content)
52
+ end
53
+
54
+ private
55
+
56
+ def chat
57
+ @chat ||= build_chat
58
+ end
59
+
60
+ def config = @config ||= Am.config
61
+ def model = @model || config.model
62
+ def provider = @provider || config.provider
63
+
64
+ # assume_model_exists: local model names aren't in RubyLLM's registry.
65
+ def build_chat
66
+ llm_context.chat(model: model, provider: provider, assume_model_exists: true)
67
+ .with_instructions(SYSTEM_PROMPT)
68
+ end
69
+
70
+ # A scoped RubyLLM context (a dup of the global config), so embedding
71
+ # robot_lab-am in a larger app never mutates that app's RubyLLM setup.
72
+ # api_base is an OpenAI-compatible-server concern and is only applied
73
+ # to the :openai provider.
74
+ def llm_context
75
+ RubyLLM.context do |llm|
76
+ llm.openai_api_base = config.api_base if provider == :openai
77
+ key_setter = :"#{provider}_api_key="
78
+ llm.public_send(key_setter, api_key) if llm.respond_to?(key_setter)
79
+ end
80
+ end
81
+
82
+ # Cascade: explicit config -> the provider's conventional env var
83
+ # (OPENAI_API_KEY, ANTHROPIC_API_KEY, ...) -> placeholder for keyless
84
+ # local servers.
85
+ def api_key
86
+ config.api_key || ENV.fetch("#{provider.to_s.upcase}_API_KEY") { PLACEHOLDER_API_KEY }
87
+ end
88
+
89
+ def build_prompt(events, repo)
90
+ lines = events.sort_by(&:timestamp).map { |e| "[#{e.timestamp}] (#{e.source}/#{e.kind}) #{e.summary}" }
91
+ "Repo: #{repo}\n\nActivity log, oldest to newest:\n#{lines.join("\n")}"
92
+ end
93
+
94
+ def parse(response)
95
+ data = YAML.safe_load(extract_yaml(response), permitted_classes: [Symbol]) || {}
96
+ build_intent(data, response)
97
+ rescue Psych::SyntaxError, Psych::DisallowedClass
98
+ build_intent({}, response)
99
+ end
100
+
101
+ def extract_yaml(response)
102
+ response[/```ya?ml\n(.*?)```/m, 1] || response
103
+ end
104
+
105
+ # :reek:FeatureEnvy -- this method's whole job is reading the parsed
106
+ # YAML hash into an Intent; that's not a sign it belongs elsewhere.
107
+ def build_intent(data, response)
108
+ Intent.new(
109
+ goal: data["goal"] || response.strip,
110
+ confidence: data["confidence"] || "unknown",
111
+ evidence: Array(data["evidence"]),
112
+ open_questions: Array(data["open_questions"]),
113
+ generated_at: Time.now.utc.iso8601
114
+ )
115
+ end
116
+
117
+ def empty_intent
118
+ Intent.new(goal: "No recent activity detected.", confidence: "low", evidence: [],
119
+ open_questions: [], generated_at: Time.now.utc.iso8601)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Am
5
+ # The inferred goal/direction, structured per ARCHITECTURE.md's
6
+ # "Inference engine" section.
7
+ Intent = Data.define(:goal, :confidence, :evidence, :open_questions, :generated_at)
8
+ end
9
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module RobotLab
7
+ module Am
8
+ # Writes the inferred Intent to .robot_lab_am/current_intent.md in the
9
+ # watched repo — front matter + prose, the same shape as robot_lab-to's
10
+ # decision files, under the same .robot_lab_<name>/ naming convention as
11
+ # robot_lab-to's own .robot_lab_to/runs/<run_id>/ run state.
12
+ class IntentWriter
13
+ def initialize(repo:)
14
+ @path = File.join(repo, ".robot_lab_am", "current_intent.md")
15
+ end
16
+
17
+ def write(intent)
18
+ FileUtils.mkdir_p(File.dirname(@path))
19
+ File.write(@path, render(intent))
20
+ @path
21
+ end
22
+
23
+ private
24
+
25
+ def render(intent)
26
+ front_matter = {
27
+ "confidence" => intent.confidence,
28
+ "generated_at" => intent.generated_at,
29
+ "evidence" => intent.evidence,
30
+ "open_questions" => intent.open_questions
31
+ }
32
+ "#{YAML.dump(front_matter)}---\n\n#{intent.goal}\n"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "rbconfig"
6
+
7
+ module RobotLab
8
+ module Am
9
+ # Generates and installs the per-repo launchd agent plist from
10
+ # ARCHITECTURE.md's Deployment model — process supervision, start on
11
+ # login, restart on crash. One agent per watched repo; the agent just
12
+ # runs `am start --foreground` and lets launchd own the process.
13
+ #
14
+ # Install/uninstall only touch the plist file; loading it into launchd is
15
+ # left to the user (the CLI prints the launchctl commands) so this class
16
+ # has no side effects beyond the file it writes.
17
+ class Launchd
18
+ LABEL_PREFIX = "com.madbomber.robot-lab-am"
19
+ DEFAULT_AGENTS_DIR = File.expand_path("~/Library/LaunchAgents")
20
+
21
+ def initialize(repo:, interval: nil, debounce: nil,
22
+ agents_dir: DEFAULT_AGENTS_DIR, program: nil, config: nil)
23
+ @repo = repo
24
+ @interval = interval
25
+ @debounce = debounce
26
+ @agents_dir = agents_dir
27
+ @program = program
28
+ @config = config
29
+ end
30
+
31
+ # Repo basename plus a short path hash: readable, and unique even for
32
+ # two checkouts with the same basename.
33
+ def label
34
+ "#{LABEL_PREFIX}.#{File.basename(@repo)}-#{Digest::SHA256.hexdigest(@repo)[0, 8]}"
35
+ end
36
+
37
+ def plist_path
38
+ File.join(@agents_dir, "#{label}.plist")
39
+ end
40
+
41
+ def install
42
+ FileUtils.mkdir_p(@agents_dir)
43
+ File.write(plist_path, plist_xml)
44
+ plist_path
45
+ end
46
+
47
+ def uninstall
48
+ raise Error, "no launchd agent installed for #{@repo} (expected #{plist_path})" unless File.exist?(plist_path)
49
+
50
+ FileUtils.rm_f(plist_path)
51
+ plist_path
52
+ end
53
+
54
+ def plist_xml
55
+ log = File.join(@repo, ".robot_lab_am", "daemon.log")
56
+ <<~XML
57
+ <?xml version="1.0" encoding="UTF-8"?>
58
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
59
+ <plist version="1.0">
60
+ <dict>
61
+ <key>Label</key>
62
+ <string>#{xml_escape(label)}</string>
63
+ <key>ProgramArguments</key>
64
+ <array>
65
+ #{program_arguments.map { |arg| " <string>#{xml_escape(arg)}</string>" }.join("\n")}
66
+ </array>
67
+ <key>WorkingDirectory</key>
68
+ <string>#{xml_escape(@repo)}</string>
69
+ <key>RunAtLoad</key>
70
+ <true/>
71
+ <key>KeepAlive</key>
72
+ <true/>
73
+ <key>StandardOutPath</key>
74
+ <string>#{xml_escape(log)}</string>
75
+ <key>StandardErrorPath</key>
76
+ <string>#{xml_escape(log)}</string>
77
+ </dict>
78
+ </plist>
79
+ XML
80
+ end
81
+
82
+ private
83
+
84
+ # launchd starts agents with a minimal PATH, so invoke the script with
85
+ # the same ruby this process is running under rather than trusting the
86
+ # shebang's `env ruby` to resolve.
87
+ def config = @config ||= Am.config
88
+ def interval = @interval || config.interval
89
+ def debounce = @debounce || config.debounce
90
+
91
+ def program
92
+ @program ||= File.expand_path($PROGRAM_NAME)
93
+ end
94
+
95
+ # interval/debounce are baked into the plist so the launchd-run daemon
96
+ # behaves the same as the `am install` invocation that created it,
97
+ # regardless of later config-file edits.
98
+ def program_arguments
99
+ [RbConfig.ruby, program, "start", "--foreground", "--repo", @repo,
100
+ "--interval", interval.to_s, "--debounce", debounce.to_s]
101
+ end
102
+
103
+ def xml_escape(text)
104
+ text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module RobotLab
6
+ module Am
7
+ # The daemon's pid on disk (.robot_lab_am/daemon.pid). A pid file alone
8
+ # can lie after a crash, which is why the daemon also writes a Heartbeat —
9
+ # but liveness here is still checked against the real process table
10
+ # (signal 0), not just the file's existence.
11
+ class PidFile
12
+ def initialize(path)
13
+ @path = path
14
+ end
15
+
16
+ def write(pid = Process.pid)
17
+ FileUtils.mkdir_p(File.dirname(@path))
18
+ File.write(@path, pid.to_s)
19
+ end
20
+
21
+ def read
22
+ return nil unless File.exist?(@path)
23
+
24
+ pid = File.read(@path).to_i
25
+ pid.positive? ? pid : nil
26
+ end
27
+
28
+ def delete
29
+ FileUtils.rm_f(@path)
30
+ end
31
+
32
+ def exist?
33
+ File.exist?(@path)
34
+ end
35
+
36
+ def alive?
37
+ pid = read
38
+ return false unless pid
39
+
40
+ Process.kill(0, pid)
41
+ true
42
+ rescue Errno::ESRCH
43
+ false
44
+ rescue Errno::EPERM
45
+ true
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Am
5
+ # Masks obvious secrets in event summaries before they reach the event
6
+ # store or an LLM, per ARCHITECTURE.md's Privacy & redaction section.
7
+ # Terminal commands and git WIP output are the risky sources (API keys in
8
+ # `export` statements, credentials in fixtures). Deliberately pattern-based
9
+ # and conservative — it catches the common shapes, not every possible leak.
10
+ module Redactor
11
+ MASK = "[REDACTED]"
12
+
13
+ # `NAME=value` / `name: value` where the name smells like a credential.
14
+ KEY_VALUE = /(\b[\w-]*(?:key|token|secret|password|passwd|credential)s?\b["']?\s*(?:=>|[=:])\s*["']?)([^\s"']+)/i
15
+
16
+ # `Authorization: Bearer <token>` and friends.
17
+ BEARER = /\b(bearer\s+)([^\s"']+)/i
18
+
19
+ # Well-known token formats that are secrets wherever they appear.
20
+ KNOWN_TOKEN = /
21
+ \b(?:
22
+ sk-[A-Za-z0-9_-]{16,} # OpenAI-style
23
+ | AKIA[0-9A-Z]{16} # AWS access key id
24
+ | gh[pousr]_[A-Za-z0-9]{20,} # GitHub tokens
25
+ | github_pat_[A-Za-z0-9_]{20,} # GitHub fine-grained PAT
26
+ | xox[abprs]-[A-Za-z0-9-]{10,} # Slack tokens
27
+ )\b
28
+ /x
29
+
30
+ module_function
31
+
32
+ def redact_event(event)
33
+ event.with(summary: redact(event.summary))
34
+ end
35
+
36
+ def redact(text)
37
+ text.gsub(KEY_VALUE, "\\1#{MASK}")
38
+ .gsub(BEARER, "\\1#{MASK}")
39
+ .gsub(KNOWN_TOKEN, MASK)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Am
5
+ VERSION = "0.0.1"
6
+ end
7
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RobotLab
6
+ module Am
7
+ module Watchers
8
+ # The human's own plain-language messages from their most recent
9
+ # Claude Code session transcript for this repo. Richest, lowest-effort
10
+ # source per ARCHITECTURE.md — already on disk, no new instrumentation.
11
+ class ClaudeWatcher
12
+ PROJECTS_DIR = File.expand_path("~/.claude/projects")
13
+
14
+ def initialize(repo:, limit: 20, projects_dir: PROJECTS_DIR)
15
+ @repo = File.expand_path(repo)
16
+ @limit = limit
17
+ @projects_dir = projects_dir
18
+ end
19
+
20
+ def events
21
+ file = latest_transcript
22
+ return [] unless file
23
+
24
+ human_messages(file)
25
+ end
26
+
27
+ private
28
+
29
+ def latest_transcript
30
+ dir = project_dir
31
+ return nil unless dir
32
+
33
+ Dir.glob(File.join(dir, "*.jsonl")).max_by { |f| File.mtime(f) }
34
+ end
35
+
36
+ # Claude Code slugifies the cwd into a project directory name by
37
+ # replacing every non-alphanumeric character with "-". Older
38
+ # sessions kept underscores; fall back to a normalized match so
39
+ # both schemes resolve to the same repo.
40
+ def project_dir
41
+ slug = @repo.gsub(/[^a-zA-Z0-9]/, "-")
42
+ exact = File.join(@projects_dir, slug)
43
+ return exact if Dir.exist?(exact)
44
+
45
+ normalized = slug.tr("_", "-")
46
+ Dir.glob(File.join(@projects_dir, "*")).find do |dir|
47
+ File.directory?(dir) && File.basename(dir).tr("_", "-") == normalized
48
+ end
49
+ end
50
+
51
+ def human_messages(file)
52
+ File.readlines(file).last(500).filter_map { |line| human_message_event(line) }.last(@limit)
53
+ end
54
+
55
+ # :reek:FeatureEnvy -- parsing one transcript line into an Event is
56
+ # inherently reading `record`/`text` more than `self`.
57
+ def human_message_event(line)
58
+ record = JSON.parse(line)
59
+ return nil unless record["type"] == "user"
60
+
61
+ text = extract_text(record.dig("message", "content"))
62
+ return nil if text.nil? || text.strip.empty?
63
+
64
+ Event.new(timestamp: record["timestamp"], repo: @repo, source: "claude_code",
65
+ kind: "user_message", summary: text.strip[0, 500])
66
+ rescue JSON::ParserError
67
+ nil
68
+ end
69
+
70
+ # Only plain text blocks count as "the human said something" — this
71
+ # skips synthetic user turns that are actually tool_result payloads.
72
+ def extract_text(content)
73
+ return content if content.is_a?(String)
74
+ return nil unless content.is_a?(Array)
75
+
76
+ blocks = content.select { |block| block["type"] == "text" }
77
+ return nil if blocks.empty?
78
+
79
+ blocks.map { |block| block["text"] }.join("\n")
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RobotLab
6
+ module Am
7
+ module Watchers
8
+ # Recent commits plus current uncommitted state, for one repo.
9
+ # All subprocess calls use explicit argv arrays (no shell interpolation).
10
+ class GitWatcher
11
+ GIT_ENV = { "GIT_TERMINAL_PROMPT" => "0" }.freeze
12
+ LOG_FORMAT = "%H%x09%aI%x09%s"
13
+
14
+ def initialize(repo:, limit: 20)
15
+ @repo = repo
16
+ @limit = limit
17
+ end
18
+
19
+ def events
20
+ commit_events + wip_events
21
+ end
22
+
23
+ private
24
+
25
+ def commit_events
26
+ out, _err, status = Open3.capture3(
27
+ GIT_ENV, "git", "log", "-n", @limit.to_s, "--format=#{LOG_FORMAT}", chdir: @repo
28
+ )
29
+ return [] unless status.success?
30
+
31
+ out.each_line.filter_map { |line| commit_event(line) }
32
+ end
33
+
34
+ def commit_event(line)
35
+ sha, iso_time, subject = line.chomp.split("\t", 3)
36
+ return nil unless sha && iso_time
37
+
38
+ Event.new(timestamp: iso_time, repo: @repo, source: "git", kind: "commit",
39
+ summary: "#{sha[0, 7]} #{subject}")
40
+ end
41
+
42
+ def wip_events
43
+ out, _err, status = Open3.capture3(GIT_ENV, "git", "status", "--short", chdir: @repo)
44
+ return [] unless status.success?
45
+
46
+ # This gem's own state dir is not the human's work — reporting it
47
+ # would make the daemon observe (and infer over) its own footprint.
48
+ lines = out.lines.map(&:chomp).reject { |line| line.empty? || line.include?(".robot_lab_am") }
49
+ return [] if lines.empty?
50
+
51
+ [Event.new(timestamp: Time.now.utc.iso8601, repo: @repo, source: "git", kind: "wip",
52
+ summary: "Uncommitted changes:\n#{lines.join("\n")}")]
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Am
5
+ module Watchers
6
+ # Commands run in the repo, read from the global preexec log written
7
+ # by ~/.bashrc__activity_monitor. That log spans every shell on the
8
+ # machine (see ARCHITECTURE.md's Privacy & redaction section) — the
9
+ # opt-in boundary is enforced here, at read time, by filtering to
10
+ # lines whose cwd falls under this one watched repo root.
11
+ class TerminalWatcher
12
+ DEFAULT_LOG_PATH = File.expand_path("~/.activity_monitor/terminal_activity.log")
13
+
14
+ def initialize(repo:, log_path: DEFAULT_LOG_PATH, limit: 50)
15
+ @repo = File.expand_path(repo)
16
+ @log_path = log_path
17
+ @limit = limit
18
+ end
19
+
20
+ def events
21
+ return [] unless File.exist?(@log_path)
22
+
23
+ File.readlines(@log_path).filter_map { |line| command_event(line) }.last(@limit)
24
+ end
25
+
26
+ private
27
+
28
+ def command_event(line)
29
+ epoch, cwd, command = line.chomp.split("\t", 3)
30
+ return nil unless epoch && cwd && command && in_scope?(cwd)
31
+
32
+ Event.new(timestamp: Time.at(epoch.to_f).utc.iso8601, repo: @repo, source: "terminal",
33
+ kind: "command", summary: command)
34
+ end
35
+
36
+ def in_scope?(cwd)
37
+ cwd == @repo || cwd.start_with?("#{@repo}/")
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "am/version"
4
+ require_relative "am/config"
5
+ require_relative "am/event"
6
+ require_relative "am/event_log"
7
+ require_relative "am/redactor"
8
+ require_relative "am/intent"
9
+ require_relative "am/inferrer"
10
+ require_relative "am/intent_writer"
11
+ require_relative "am/watchers/git_watcher"
12
+ require_relative "am/watchers/claude_watcher"
13
+ require_relative "am/watchers/terminal_watcher"
14
+ require_relative "am/collector"
15
+ require_relative "am/pid_file"
16
+ require_relative "am/heartbeat"
17
+ require_relative "am/daemon"
18
+ require_relative "am/daemon_controller"
19
+ require_relative "am/launchd"
20
+ require_relative "am/cli"
21
+
22
+ module RobotLab
23
+ module Am
24
+ class Error < StandardError; end
25
+
26
+ class << self
27
+ # Process-wide Config (defaults.yml -> user config file ->
28
+ # RLAM_* env vars). Components use it for any setting not
29
+ # passed to them explicitly.
30
+ def config
31
+ @config ||= Config.new
32
+ end
33
+
34
+ # Test hook: drop the memoized config so changed env vars are re-read.
35
+ def reset_config!
36
+ @config = nil
37
+ end
38
+ end
39
+ end
40
+ end