space-architect 9.2.1 → 9.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -0
- data/README.md +2 -4
- data/lib/space_architect/architect_project.rb +3 -21
- data/lib/space_architect/cli/architect.rb +66 -295
- data/lib/space_architect/harness.rb +2 -51
- data/lib/space_architect/routing.rb +43 -0
- data/lib/space_architect/version.rb +1 -1
- data/lib/space_architect.rb +0 -4
- data/skill/architect/SKILL.md +145 -319
- data/skill/architect-action/SKILL.md +123 -0
- data/skill/architect-discuss/SKILL.md +252 -0
- data/skill/{architect → architect-discuss}/dispatch.md +5 -4
- data/skill/{architect → architect-discuss}/research.md +2 -1
- data/skill/architect-research/SKILL.md +3 -2
- data/skill/architect-status/SKILL.md +55 -0
- metadata +7 -45
- data/lib/space_architect/conversations_client.rb +0 -81
- data/lib/space_architect/jobs_client.rb +0 -147
- data/lib/space_architect/run_creator.rb +0 -53
- data/lib/space_architect/session_sync/bin_path.rb +0 -28
- data/lib/space_architect/session_sync/cursor.rb +0 -43
- data/lib/space_architect/session_sync/plist.rb +0 -111
- data/lib/space_architect/session_sync/runner.rb +0 -64
- data/lib/space_architect/session_sync/session_id.rb +0 -20
- data/lib/space_architect/session_sync.rb +0 -56
- data/lib/space_architect/space_push.rb +0 -274
- data/skill/architect-plan/SKILL.md +0 -114
- data/skill/architect-vocabulary/SKILL.md +0 -144
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "async/http/client"
|
|
4
|
-
require "async/http/endpoint"
|
|
5
|
-
require "json"
|
|
6
|
-
|
|
7
|
-
module Space::Architect
|
|
8
|
-
# HTTP client for the space-server jobs API, modeled on RunCreator: Bearer
|
|
9
|
-
# auth, injectable async-http client, Space::Core::Error on any failure.
|
|
10
|
-
class JobsClient
|
|
11
|
-
# Poll bound for #wait_for_run_id: 2s * 30 attempts = 60s. Both are
|
|
12
|
-
# injectable kwargs so tests need not sleep real seconds.
|
|
13
|
-
POLL_INTERVAL_SECONDS = 2
|
|
14
|
-
POLL_MAX_ATTEMPTS = 30
|
|
15
|
-
|
|
16
|
-
def initialize(host, token, client: nil)
|
|
17
|
-
@host = host.chomp("/")
|
|
18
|
-
@token = token
|
|
19
|
-
@client = client
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# GET /jobs — returns the "jobs" array (owner-scoped, newest-first).
|
|
23
|
-
def list
|
|
24
|
-
get_json("/jobs")["jobs"]
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# GET /jobs/:id — returns the job JSON.
|
|
28
|
-
def show(id)
|
|
29
|
-
get_json("/jobs/#{id}")
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# POST /jobs — submits a job spec (harness/prompt/environment) and returns the
|
|
33
|
-
# created job's integer id. Raises Space::Core::Error on any failure, modeled on
|
|
34
|
-
# RunCreator#create (same 20x-status + integer-id checks).
|
|
35
|
-
def create(spec)
|
|
36
|
-
Sync do
|
|
37
|
-
with_client do |c|
|
|
38
|
-
response = c.post("/jobs", headers: headers, body: JSON.generate(spec))
|
|
39
|
-
parsed = parse_json(response, "POST /jobs", expected_status: 201)
|
|
40
|
-
id = parsed["id"]
|
|
41
|
-
raise Space::Core::Error, "POST /jobs: missing or non-integer id in response: #{parsed.inspect[0, 200]}" \
|
|
42
|
-
unless id.is_a?(Integer)
|
|
43
|
-
|
|
44
|
-
id
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# POST /jobs/:id/cancel — returns {"id"=>.., "status"=>"canceled"}.
|
|
50
|
-
def cancel(id)
|
|
51
|
-
Sync do
|
|
52
|
-
with_client do |c|
|
|
53
|
-
response = c.post("/jobs/#{id}/cancel", headers: headers, body: nil)
|
|
54
|
-
parse_json(response, "POST /jobs/#{id}/cancel")
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# GET /runs/:id/stream — yields each SSE event's data: payload to the
|
|
60
|
-
# block as it arrives. Returns when the stream reports run_complete or
|
|
61
|
-
# closes cleanly. Raises Space::Core::Error on a non-200 response.
|
|
62
|
-
def stream(run_id, &block)
|
|
63
|
-
Sync do
|
|
64
|
-
with_client do |c|
|
|
65
|
-
response = c.get("/runs/#{run_id}/stream", headers: headers)
|
|
66
|
-
unless response.status == 200
|
|
67
|
-
body = response.read || ""
|
|
68
|
-
raise Space::Core::Error, "GET /runs/#{run_id}/stream failed (#{response.status}): #{body[0, 200]}"
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
read_sse(response, &block)
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# Polls GET /jobs/:id until run_id is present, waiting `interval` seconds
|
|
77
|
-
# between attempts, up to `attempts` tries. Raises Space::Core::Error if
|
|
78
|
-
# the bound is exceeded.
|
|
79
|
-
def wait_for_run_id(id, interval: POLL_INTERVAL_SECONDS, attempts: POLL_MAX_ATTEMPTS)
|
|
80
|
-
attempts.times do |i|
|
|
81
|
-
run_id = show(id)["run_id"]
|
|
82
|
-
return run_id if run_id
|
|
83
|
-
sleep interval unless i == attempts - 1
|
|
84
|
-
end
|
|
85
|
-
raise Space::Core::Error, "job #{id}: no run_id after #{attempts} attempts (#{attempts * interval}s)"
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
private
|
|
89
|
-
|
|
90
|
-
def get_json(path)
|
|
91
|
-
Sync do
|
|
92
|
-
with_client do |c|
|
|
93
|
-
response = c.get(path, headers: headers)
|
|
94
|
-
parse_json(response, "GET #{path}")
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def with_client
|
|
100
|
-
if @client
|
|
101
|
-
yield @client
|
|
102
|
-
else
|
|
103
|
-
Async::HTTP::Client.open(Async::HTTP::Endpoint.parse(@host)) { |c| yield c }
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def headers
|
|
108
|
-
[
|
|
109
|
-
["authorization", "Bearer #{@token}"],
|
|
110
|
-
["content-type", "application/json"]
|
|
111
|
-
]
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def parse_json(response, what, expected_status: 200)
|
|
115
|
-
status = response.status
|
|
116
|
-
body = response.read || ""
|
|
117
|
-
raise Space::Core::Error, "#{what} failed (#{status}): #{body[0, 200]}" unless status == expected_status
|
|
118
|
-
|
|
119
|
-
JSON.parse(body)
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
# Buffers chunks and splits on the SSE event terminator ("\n\n"). Each
|
|
123
|
-
# event's data: line(s) are joined and yielded; an event whose data JSON
|
|
124
|
-
# carries type == "run_complete" ends the stream.
|
|
125
|
-
def read_sse(response)
|
|
126
|
-
buffer = +""
|
|
127
|
-
response.each do |chunk|
|
|
128
|
-
buffer << chunk
|
|
129
|
-
while (boundary = buffer.index("\n\n"))
|
|
130
|
-
event = buffer.slice!(0..boundary + 1)
|
|
131
|
-
data = event.each_line.select { |line| line.start_with?("data:") }
|
|
132
|
-
.map { |line| line.sub(/\Adata:\s?/, "").chomp }
|
|
133
|
-
.join("\n")
|
|
134
|
-
next if data.empty?
|
|
135
|
-
|
|
136
|
-
yield data
|
|
137
|
-
parsed = begin
|
|
138
|
-
JSON.parse(data)
|
|
139
|
-
rescue JSON::ParserError
|
|
140
|
-
nil
|
|
141
|
-
end
|
|
142
|
-
return if parsed.is_a?(Hash) && parsed["type"] == "run_complete"
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
end
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "async/http/client"
|
|
4
|
-
require "async/http/endpoint"
|
|
5
|
-
require "json"
|
|
6
|
-
|
|
7
|
-
module Space::Architect
|
|
8
|
-
class RunCreator
|
|
9
|
-
def initialize(host, token, client: nil)
|
|
10
|
-
@host = host.chomp("/")
|
|
11
|
-
@token = token
|
|
12
|
-
@client = client
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
# POSTs to /runs and returns the integer run id.
|
|
16
|
-
# Raises Space::Core::Error on any failure — never returns nil.
|
|
17
|
-
def create
|
|
18
|
-
Sync do
|
|
19
|
-
if @client
|
|
20
|
-
response = @client.post("/runs", headers: headers, body: nil)
|
|
21
|
-
parse_response(response)
|
|
22
|
-
else
|
|
23
|
-
Async::HTTP::Client.open(Async::HTTP::Endpoint.parse(@host)) do |c|
|
|
24
|
-
response = c.post("/runs", headers: headers, body: nil)
|
|
25
|
-
parse_response(response)
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
private
|
|
32
|
-
|
|
33
|
-
def headers
|
|
34
|
-
[
|
|
35
|
-
["authorization", "Bearer #{@token}"],
|
|
36
|
-
["content-type", "application/json"]
|
|
37
|
-
]
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def parse_response(response)
|
|
41
|
-
status = response.status
|
|
42
|
-
body = response.read || ""
|
|
43
|
-
raise Space::Core::Error, "POST /runs failed (#{status}): #{body[0, 200]}" unless status == 201
|
|
44
|
-
|
|
45
|
-
parsed = JSON.parse(body)
|
|
46
|
-
id = parsed["id"]
|
|
47
|
-
raise Space::Core::Error, "POST /runs: missing or non-integer id in response: #{body[0, 200]}" \
|
|
48
|
-
unless id.is_a?(Integer)
|
|
49
|
-
|
|
50
|
-
id
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "open3"
|
|
4
|
-
|
|
5
|
-
module Space::Architect
|
|
6
|
-
module SessionSync
|
|
7
|
-
# Resolves the absolute path to the `architect` executable the plist
|
|
8
|
-
# should invoke, imitating RepoTender::CLI::Daemon::Helpers::Resolve's
|
|
9
|
-
# detect_bin_path precedence: an env override (tests), the dev checkout's
|
|
10
|
-
# exe/architect, `which architect`, then the installed gem's bin.
|
|
11
|
-
module BinPath
|
|
12
|
-
module_function
|
|
13
|
-
|
|
14
|
-
def detect(env: ENV)
|
|
15
|
-
override = env["SPACE_ARCHITECT_BIN_PATH"]
|
|
16
|
-
return override if override && !override.empty?
|
|
17
|
-
|
|
18
|
-
dev = File.expand_path("../../../exe/architect", __dir__)
|
|
19
|
-
return dev if File.exist?(dev)
|
|
20
|
-
|
|
21
|
-
out, _err, status = Open3.capture3("which", "architect")
|
|
22
|
-
return out.strip if status.success? && !out.strip.empty?
|
|
23
|
-
|
|
24
|
-
Gem.bin_path("space-architect", "architect")
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "yaml"
|
|
4
|
-
require "fileutils"
|
|
5
|
-
|
|
6
|
-
module Space::Architect
|
|
7
|
-
module SessionSync
|
|
8
|
-
# Machine-managed cursor at $XDG_STATE_HOME/space-architect/session-sync.yaml
|
|
9
|
-
# (imitates RepoTender::State::Store's load/write/emit shape). Keyed by
|
|
10
|
-
# absolute source path; each entry records the size + mtime observed the
|
|
11
|
-
# last time that file was uploaded.
|
|
12
|
-
class Cursor
|
|
13
|
-
Entry = Data.define(:size, :mtime) do
|
|
14
|
-
def initialize(size:, mtime:) = super
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def self.load(path)
|
|
18
|
-
raw = read_yaml(path)
|
|
19
|
-
raw.each_with_object({}) do |(k, v), acc|
|
|
20
|
-
acc[k] = Entry.new(size: v["size"], mtime: v["mtime"])
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def self.write(path, entries)
|
|
25
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
26
|
-
payload = entries.each_with_object({}) { |(k, v), acc| acc[k] = {"size" => v.size, "mtime" => v.mtime} }
|
|
27
|
-
tmp = "#{path}.tmp.#{Process.pid}"
|
|
28
|
-
begin
|
|
29
|
-
File.write(tmp, YAML.dump(payload, line_width: -1))
|
|
30
|
-
File.rename(tmp, path)
|
|
31
|
-
ensure
|
|
32
|
-
File.delete(tmp) if File.exist?(tmp)
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def self.read_yaml(path)
|
|
37
|
-
return {} unless File.exist?(path)
|
|
38
|
-
|
|
39
|
-
YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Space::Architect
|
|
4
|
-
module SessionSync
|
|
5
|
-
# Hand-rolled launchd plist emitter for the session-sync agent, imitating
|
|
6
|
-
# RepoTender::Launchd::Plist's shape. Under launchd's bare PATH, a bare
|
|
7
|
-
# env-relative ruby binstub shebang resolves to macOS system Ruby instead
|
|
8
|
-
# of the caller's toolchain, so ProgramArguments names the interpreter
|
|
9
|
-
# explicitly (ruby_bin) rather than relying on shebang resolution — no
|
|
10
|
-
# mise wrapping needed, since the sync itself pins no toolchain at run
|
|
11
|
-
# time.
|
|
12
|
-
class Plist
|
|
13
|
-
DEFAULT_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
14
|
-
|
|
15
|
-
HEADER = <<~XML
|
|
16
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
17
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
18
|
-
<plist version="1.0">
|
|
19
|
-
<dict>
|
|
20
|
-
XML
|
|
21
|
-
FOOTER = "</dict>\n</plist>\n"
|
|
22
|
-
|
|
23
|
-
class << self
|
|
24
|
-
# @param label [String] The job label (reverse-DNS; on-disk plist basename).
|
|
25
|
-
# @param refresh_interval [Integer] StartInterval in seconds (must be > 0).
|
|
26
|
-
# @param log_dir [String] Absolute directory for stdout/stderr logs.
|
|
27
|
-
# @param bin_path [String] Absolute path to the architect bin script.
|
|
28
|
-
# @param ruby_bin [String] Absolute path to the ruby to run bin_path under.
|
|
29
|
-
# @param host [String] --host value passed to `sessions sync`.
|
|
30
|
-
# @param env [Hash] EnvironmentVariables dict; must contain a non-empty
|
|
31
|
-
# SessionSync::TOKEN_ENV value (resolved by the caller —
|
|
32
|
-
# never resolved here). PATH defaults to DEFAULT_PATH
|
|
33
|
-
# unless the caller supplies its own.
|
|
34
|
-
# @return [String] the full plist XML.
|
|
35
|
-
def call(label:, refresh_interval:, log_dir:, bin_path:, ruby_bin:, host:, env:)
|
|
36
|
-
raise ArgumentError, "label is required" if label.to_s.empty?
|
|
37
|
-
raise ArgumentError, "refresh_interval must be > 0" unless refresh_interval.is_a?(Integer) && refresh_interval > 0
|
|
38
|
-
%w[log_dir bin_path ruby_bin].each do |k|
|
|
39
|
-
v = binding.local_variable_get(k)
|
|
40
|
-
raise ArgumentError, "#{k} must be absolute (got #{v.inspect})" unless v.is_a?(String) && File.absolute_path?(v)
|
|
41
|
-
end
|
|
42
|
-
raise ArgumentError, "host is required" if host.to_s.empty?
|
|
43
|
-
raise ArgumentError, "env[#{SessionSync::TOKEN_ENV}] is required" if env.to_h[SessionSync::TOKEN_ENV].to_s.empty?
|
|
44
|
-
|
|
45
|
-
out_log = File.join(log_dir, "#{label}.out.log")
|
|
46
|
-
err_log = File.join(log_dir, "#{label}.err.log")
|
|
47
|
-
|
|
48
|
-
body = +""
|
|
49
|
-
body << key("Label") << string(label) << "\n"
|
|
50
|
-
body << key("ProgramArguments") << "\n" << array([
|
|
51
|
-
ruby_bin,
|
|
52
|
-
bin_path,
|
|
53
|
-
"sessions",
|
|
54
|
-
"sync",
|
|
55
|
-
"--host", host
|
|
56
|
-
])
|
|
57
|
-
body << key("EnvironmentVariables") << "\n" << dict({"PATH" => DEFAULT_PATH}.merge(env.to_h))
|
|
58
|
-
body << key("StartInterval") << integer(refresh_interval) << "\n"
|
|
59
|
-
body << key("RunAtLoad") << boolean(true) << "\n"
|
|
60
|
-
body << key("ProcessType") << string("Background") << "\n"
|
|
61
|
-
body << key("StandardOutPath") << string(out_log) << "\n"
|
|
62
|
-
body << key("StandardErrorPath") << string(err_log) << "\n"
|
|
63
|
-
|
|
64
|
-
HEADER + body + FOOTER
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
private
|
|
68
|
-
|
|
69
|
-
def key(name)
|
|
70
|
-
" <key>#{escape(name)}</key>\n"
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def escape(s)
|
|
74
|
-
s.to_s
|
|
75
|
-
.gsub("&", "&")
|
|
76
|
-
.gsub("<", "<")
|
|
77
|
-
.gsub(">", ">")
|
|
78
|
-
.gsub("\"", """)
|
|
79
|
-
.gsub("'", "'")
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def string(s)
|
|
83
|
-
" <string>#{escape(s)}</string>"
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def integer(i)
|
|
87
|
-
" <integer>#{Integer(i)}</integer>"
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def boolean(b)
|
|
91
|
-
" <#{b ? "true" : "false"}/>"
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def array(items)
|
|
95
|
-
out = +" <array>\n"
|
|
96
|
-
items.each { |arg| out << " <string>#{escape(arg)}</string>\n" }
|
|
97
|
-
out << " </array>"
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def dict(hash)
|
|
101
|
-
out = +" <dict>\n"
|
|
102
|
-
hash.each do |k, v|
|
|
103
|
-
out << " <key>#{escape(k)}</key>\n"
|
|
104
|
-
out << " <string>#{escape(v)}</string>\n"
|
|
105
|
-
end
|
|
106
|
-
out << " </dict>"
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
end
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "cursor"
|
|
4
|
-
require_relative "session_id"
|
|
5
|
-
|
|
6
|
-
module Space::Architect
|
|
7
|
-
module SessionSync
|
|
8
|
-
# One sync pass: scans the pi + claude session roots, decides
|
|
9
|
-
# upload/skip per the frozen cursor rules, uploads via the injected
|
|
10
|
-
# client, and persists the updated cursor (unless dry_run).
|
|
11
|
-
class Runner
|
|
12
|
-
RECENT_MTIME_SECONDS = 60
|
|
13
|
-
|
|
14
|
-
def initialize(client:, state_path:, pi_root:, claude_root:, now: -> { Time.now }, dry_run: false)
|
|
15
|
-
@client = client
|
|
16
|
-
@state_path = state_path
|
|
17
|
-
@pi_root = pi_root
|
|
18
|
-
@claude_root = claude_root
|
|
19
|
-
@now = now
|
|
20
|
-
@dry_run = dry_run
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def call
|
|
24
|
-
cursor = Cursor.load(@state_path)
|
|
25
|
-
results = files.map { |path, session_id| process(path, session_id, cursor) }
|
|
26
|
-
Cursor.write(@state_path, cursor) unless @dry_run
|
|
27
|
-
results
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
private
|
|
31
|
-
|
|
32
|
-
def process(path, session_id, cursor)
|
|
33
|
-
stat = File.stat(path)
|
|
34
|
-
recorded = cursor[path]
|
|
35
|
-
|
|
36
|
-
return {path: path, session_id: session_id, action: :skipped, reason: "recent mtime"} if recent_mtime?(stat)
|
|
37
|
-
return {path: path, session_id: session_id, action: :skipped, reason: "unchanged"} if recorded && recorded.size >= stat.size
|
|
38
|
-
|
|
39
|
-
return {path: path, session_id: session_id, action: :would_upload} if @dry_run
|
|
40
|
-
|
|
41
|
-
response = @client.upload(path: path, session_id: session_id)
|
|
42
|
-
if [200, 201].include?(response[:status])
|
|
43
|
-
cursor[path] = Cursor::Entry.new(size: stat.size, mtime: stat.mtime.to_i)
|
|
44
|
-
{path: path, session_id: session_id, action: response[:status] == 201 ? :uploaded : :updated,
|
|
45
|
-
conversation_id: response[:conversation_id]}
|
|
46
|
-
else
|
|
47
|
-
{path: path, session_id: session_id, action: :failed, status: response[:status], errors: response[:errors]}
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def recent_mtime?(stat)
|
|
52
|
-
(@now.call - stat.mtime) < RECENT_MTIME_SECONDS
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def files
|
|
56
|
-
pi = Space::Core::Paths.content_tree(@pi_root).select { |p| p.end_with?(".jsonl") }
|
|
57
|
-
.map { |p| [p, SessionId.for_pi(p)] }
|
|
58
|
-
claude = Space::Core::Paths.content_tree(@claude_root).select { |p| p.end_with?(".jsonl") }
|
|
59
|
-
.map { |p| [p, SessionId.for_claude(p)] }
|
|
60
|
-
pi + claude
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
end
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Space::Architect
|
|
4
|
-
module SessionSync
|
|
5
|
-
# Frozen session-id derivation rules for the two store shapes:
|
|
6
|
-
# pi: <mangled-cwd>/<timestamp>_<sessionId>.jsonl -> part after the last "_"
|
|
7
|
-
# claude: <mangled-cwd>/<sessionId>.jsonl -> basename minus ".jsonl"
|
|
8
|
-
module SessionId
|
|
9
|
-
def self.for_pi(path)
|
|
10
|
-
base = File.basename(path, ".jsonl")
|
|
11
|
-
idx = base.rindex("_")
|
|
12
|
-
idx ? base[(idx + 1)..] : base
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def self.for_claude(path)
|
|
16
|
-
File.basename(path, ".jsonl")
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "session_sync/session_id"
|
|
4
|
-
require_relative "session_sync/cursor"
|
|
5
|
-
require_relative "session_sync/runner"
|
|
6
|
-
require_relative "session_sync/plist"
|
|
7
|
-
require_relative "session_sync/bin_path"
|
|
8
|
-
|
|
9
|
-
module Space::Architect
|
|
10
|
-
# Session-sync rail's laptop half: scans ~/.pi/agent/sessions/**/*.jsonl and
|
|
11
|
-
# ~/.claude/projects/**/*.jsonl for conversation files, uploads new/grown
|
|
12
|
-
# ones to a space-server via ConversationsClient, and installs a per-user
|
|
13
|
-
# launchd agent that runs the sync on an interval. Opencode (sqlite) is out
|
|
14
|
-
# of scope for this rail.
|
|
15
|
-
module SessionSync
|
|
16
|
-
LABEL = "io.github.jetpks.space-architect.session-sync"
|
|
17
|
-
APP_NAME = "space-architect"
|
|
18
|
-
TOKEN_ENV = "SPACE_ARCHITECT_INGEST_TOKEN"
|
|
19
|
-
|
|
20
|
-
module_function
|
|
21
|
-
|
|
22
|
-
# The one op-read seam under lib/: resolves an op:// ref via `op read`.
|
|
23
|
-
# Shared by ConversationsClient's default op_resolver and
|
|
24
|
-
# Sessions::Agent::Install (both test-injectable around this call).
|
|
25
|
-
def resolve_token(ref)
|
|
26
|
-
value = IO.popen(["op", "read", ref], &:read)
|
|
27
|
-
raise Space::Core::Error, "op read failed for #{ref}" unless $?.success?
|
|
28
|
-
|
|
29
|
-
value.chomp
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def state_dir(env: ENV)
|
|
33
|
-
File.join(Space::Core::XDG.state_home(env: env).to_s, APP_NAME)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def default_state_file(env: ENV)
|
|
37
|
-
File.join(state_dir(env: env), "session-sync.yaml")
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def default_pi_root(env: ENV)
|
|
41
|
-
File.join(Space::Core::XDG.home(env: env), ".pi", "agent", "sessions")
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def default_claude_root(env: ENV)
|
|
45
|
-
File.join(Space::Core::XDG.home(env: env), ".claude", "projects")
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def log_dir(env: ENV)
|
|
49
|
-
File.join(state_dir(env: env), "logs")
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def launch_agents_dir(env: ENV)
|
|
53
|
-
File.join(Space::Core::XDG.home(env: env), "Library", "LaunchAgents")
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|